Skip to content

PlButton

A control that runs an action. Use it for anything the user deliberately triggers, submitting a form, saving, deleting.

React
tsx
import { PlButton } from 'plass-ui';

<PlButton onClick={save}>Save</PlButton>;
dart
import 'package:plass_ui/plass_ui.dart';

PlButton(onPressed: save, child: const Text('Save'));

Props

PropTypeDefaultDescription
variantshared'solid' | 'glass' | 'ghost''solid'What the surface is made of. solid is a pane of tinted glass whose gradient turns in hue, glass is a clear sheet, ghost has no surface at all
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale. xs 24px · sm 32px · md 40px · lg 48px · xl 56px
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
elevationshared0 | 1 | 2 | 31Drop shadow depth. A control rests on the sheet, so the default is 1. Hover adds a level and pressing removes one, putting it down on the sheet
startIconReactNodeContent before the label. Sized in em, so it tracks the label
endIconReactNodeContent after the label
loadingbooleanfalseSpinner in place of startIcon; stops activation but keeps focus
readOnlybooleanfalseInert but not dimmed — the action exists, it just is not available here
disabledbooleanfalseUnavailable. Loses its light and its shadow, lets the page through, and leaves the tab order
fullWidthbooleanfalseStretches to the width of the container
renderuseRender.RenderPropRenders something other than a button (an <a href>, a router Link). A link stays a link, so crawlers and screen readers still see one
childrenReactNodeThe label. Omit it and the button goes square for an icon
PropTypeDefaultDescription
variantsharedPlassVariant?PlassVariant.solidWhat the surface is made of. solid is a pane of tinted glass whose gradient turns in hue, glass is a clear sheet, ghost has no surface at all
sizesharedPlassSize?PlassSize.mdHeight and type scale. xs 24px · sm 32px · md 40px · lg 48px · xl 56px
colorsharedPlassColor?PlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
densitysharedPlassDensity?PlassDensity.standardPadding only — never the height, never the type scale
elevationsharedint?1Drop shadow depth. A control rests on the sheet, so the default is 1. Hover adds a level and pressing removes one, putting it down on the sheet
startIconWidget?Content before the label. Sized in em, so it tracks the label
endIconWidget?Content after the label
loadingboolfalseSpinner in place of startIcon; stops activation but keeps focus
readOnlyboolfalseInert but not dimmed — the action exists, it just is not available here
disabledbool?falseUnavailable. Loses its light and its shadow, lets the page through, and leaves the tab order
fullWidthboolfalseStretches to the width of the container
onPressedVoidCallback?Called when pressed. Leaving it null disables the button, as it does everywhere else in Flutter
onLongPressVoidCallback?Called on a long press — the touch equivalent of a context menu
focusNodeFocusNode?Drive focus from outside. Left out, the button owns one of its own
autofocusboolfalseTakes focus as it is inserted into the tree
semanticLabelString?The name a screen reader announces. Required on an icon-only button
childWidget?The label. Omit it and the button goes square for an icon

Every native <button> attribute passes straight through. The one exception is color, omitted because it collides with the color in the table above.

PlButton needs nothing above it in the tree. Without a PlassTheme it follows the platform's own brightness, so a button dropped into any app is already in the right theme. See differences from the React build for what does not carry across.

What the shared axes (variant size color density elevation) mean across the library is in prop conventions.

Examples

variant

solid is a pane of tinted glass and the primary action. glass is a clear sheet with a hairline, for secondary actions. ghost has no surface until the pointer is on it, for a toolbar or a row. Keep one solid per screen.

A glass button wears the family in its text, so color="secondary"color: PlassColor.secondary is the quiet neutral button rather than a fourth variant.

All three carry the interaction light: a soft bloom that follows the pointer across the control, and a brighter flash on press that drains over about 700ms. On a touch screen it follows a finger dragged across the button. The bloom is white on a solid surface and the family's own tint on the other two.

React

color

Six role colours only; arbitrary colour values are not accepted. On solid the family is the gradient and the shadow under it; on glass and ghost it is the label.

React

size

Sets the height and the type scale together: xs 24px · sm 32px · md 40px · lg 48px · xl 56px. md is the desktop default, and lg and xl both clear the 44px mobile touch target.

React

density

density changes horizontal padding and nothing else. Two buttons of the same size are the same height whatever their density, so a mixed row keeps its baseline.

The standard track is PlassDensity.standard. It is spelled 'default' in the React package; default is a reserved word in Dart, and this is the only value in the shared vocabulary the two packages name differently.

React

startIcon and endIcon

Icons are drawn at 1.2em, so they track the label and never need a size of their own. With icons but no childrenchild the button goes square, and then it needs an aria-labelsemanticLabel.

The size arrives through IconTheme, which an Icon reads on its own; a glyph drawn some other way should read IconTheme.of(context) the way the demo below does.

React

loading · readOnly · disabled

propAppearanceFocusNative disabled
loadingUnchanged; a spinner takes the startIcon slotKeptNo
readOnlyKeeps its colour, goes flat, drains saturationKeptNo
disabledLoses the light and the shadow; the page shows through itLostYes
parameterAppearanceFocus
loadingUnchanged; a spinner takes the startIcon slotKept
readOnlyKeeps its colour, goes flat, drains saturationKept
disabledLoses the light and the shadow; the page shows through itLost

All three are announced as unavailable, and only disabled also leaves the focus order. Flutter has no equivalent of aria-busy, so a screen reader cannot tell loading from readOnly. Put the difference in the semanticLabel if it matters on your screen.

Leaving onPressed null does the same thing as disabled: true, which is what a Flutter developer will try first.

None of the three let a tap reach the parent.

React

elevation

Drop shadow depth. The default is 1, not 0: a key rests on the sheet. Hovering adds a level and pressing removes one, which is what puts a default button down flush against the glass under the finger.

The tinted shadow a solid button casts in its own colour is not part of this ladder and does not scale with it. elevation says how far off the page a surface is, and a danger button one level higher is not a redder pane of glass.

React

fullWidth

Stretches to the width of the container.

React

render

Renders something other than a <button>. An action that navigates should be an <a href>: a crawler follows it, it appears in a screen reader's list of links, and the browser's own behaviour (open in a new tab, copy the address) keeps working. A router's Link goes in the same way.

The surface, the sizes and the press signature are unchanged. An <a> has no disabled, so a button that has to be unavailable stays a <button>.

React

Accessibility

  • Renders a native <button> by default. type passes through, so type="submit" works inside a form.
  • Changing the element with render keeps that element's semantics: an <a href> stays a link rather than being covered by role="button".
  • Give icon-only buttons an aria-label.
  • The focus ring only appears on :focus-visible, so a mouse click never draws one.
  • loading and readOnly keep focus: dropping out of the tab order costs keyboard users their sense of the page.
  • Both ends of every gradient meet 4.5:1 against the label on them.
  • The interaction light is decorative: it carries no state, and it is not the only signal for anything. prefers-reduced-motion stops it easing.
  • Announced as a button, enabled or not, with its label read off its child.
  • Give icon-only buttons a semanticLabel.
  • The focus ring only appears on what CSS calls :focus-visible, a keyboard reaching the control, never a pointer clicking it. Flutter's name for the same distinction is FocusableActionDetector's focus highlight.
  • Enter, Space and the numpad Enter activate the button. They are bound on the button itself, so it behaves the same with or without an app widget above it.
  • loading and readOnly keep focus: dropping out of the focus order costs keyboard users their sense of the page.
  • Both ends of every gradient meet 4.5:1 against the label on them.
  • The interaction light is decorative: it carries no state, and it is not the only signal for anything. A platform with animations turned off (MediaQuery.disableAnimations) stops it easing.

Differences from the React build

Everything above is the same in both packages. These are the places where it is not, and why.

ReactFlutterWhy
renderFlutter has no polymorphic element. An action that navigates calls your router from onPressed.
className, style, native attributesThere is no class list and no style attribute to pass through. focusNode, autofocus and onLongPress are offered instead.
onClickonPressedFlutter's name, and onPressed: null disables the button the way it does everywhere else in Flutter.
childrenchildFlutter's name.
aria-labelsemanticLabelFlutter's name.
density="default"PlassDensity.standarddefault is a reserved word in Dart.
prefers-reduced-motionMediaQuery.disableAnimationsThe platform's own signal.

Two more that are not API, but are visible:

  • The font. Neither package sets one. A button inherits whatever its host uses, and supplying it is the app's job on both sides. The React previews here are drawn in the documentation site's UI font; the Flutter gallery ships Inter. It is the same button in two typefaces, not two buttons.

    This matters more than it sounds, because a label is weight 600 and not every font has one. Flutter's engine carries a single face, Roboto Regular, and synthesises anything else by widening its strokes, and Roboto's own family goes 400, 500, 700 with no 600 in it. An app on a font with no real SemiBold gets a label that is heavier and visibly softer than the one above. Inter, Pretendard, SF and Noto Sans all have the weight; Roboto does not.

  • The blur. glass blurs what is painted behind it, and in Flutter that means inside the same app. The previews here are iframes, so the gallery paints the page's backdrop itself, which is why a glass button in a Flutter preview has something to be in front of.

Everything else is matched deliberately, including the parts where the same number would have been wrong: shadow blur is converted from the CSS radius to Flutter's sigma so the two shadows are the same size, and the solid gradient computes its endpoints the way linear-gradient(135deg, …) does rather than running corner to corner, which on a wide button is a visibly different sweep.

Released under the MIT License