PlButton
A control that runs an action. Use it for anything the user deliberately triggers, submitting a form, saving, deleting.
import { PlButton } from 'plass-ui';
<PlButton onClick={save}>Save</PlButton>;import 'package:plass_ui/plass_ui.dart';
PlButton(onPressed: save, child: const Text('Save'));Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 |
| elevationshared | 0 | 1 | 2 | 3 | 1 | Drop 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 |
| startIcon | ReactNode | — | Content before the label. Sized in em, so it tracks the label |
| endIcon | ReactNode | — | Content after the label |
| loading | boolean | false | Spinner in place of startIcon; stops activation but keeps focus |
| readOnly | boolean | false | Inert but not dimmed — the action exists, it just is not available here |
| disabled | boolean | false | Unavailable. Loses its light and its shadow, lets the page through, and leaves the tab order |
| fullWidth | boolean | false | Stretches to the width of the container |
| render | useRender.RenderProp | — | Renders something other than a button (an <a href>, a router Link). A link stays a link, so crawlers and screen readers still see one |
| children | ReactNode | — | The label. Omit it and the button goes square for an icon |
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | PlassVariant? | PlassVariant.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 | PlassSize? | PlassSize.md | Height and type scale. xs 24px · sm 32px · md 40px · lg 48px · xl 56px |
| colorshared | PlassColor? | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity? | PlassDensity.standard | Padding only — never the height, never the type scale |
| elevationshared | int? | 1 | Drop 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 |
| startIcon | Widget? | — | Content before the label. Sized in em, so it tracks the label |
| endIcon | Widget? | — | Content after the label |
| loading | bool | false | Spinner in place of startIcon; stops activation but keeps focus |
| readOnly | bool | false | Inert but not dimmed — the action exists, it just is not available here |
| disabled | bool? | false | Unavailable. Loses its light and its shadow, lets the page through, and leaves the tab order |
| fullWidth | bool | false | Stretches to the width of the container |
| onPressed | VoidCallback? | — | Called when pressed. Leaving it null disables the button, as it does everywhere else in Flutter |
| onLongPress | VoidCallback? | — | Called on a long press — the touch equivalent of a context menu |
| focusNode | FocusNode? | — | Drive focus from outside. Left out, the button owns one of its own |
| autofocus | bool | false | Takes focus as it is inserted into the tree |
| semanticLabel | String? | — | The name a screen reader announces. Required on an icon-only button |
| child | Widget? | — | 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.
import { PlButton } from 'plass-ui';
export default function ButtonVariants() {
return (
<div className="flex flex-wrap items-center gap-3">
<PlButton variant="solid">Save</PlButton>
<PlButton variant="glass">Cancel</PlButton>
<PlButton variant="glass" color="secondary">
Dismiss
</PlButton>
<PlButton variant="ghost">Details</PlButton>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ButtonVariants extends StatelessWidget {
const ButtonVariants({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
PlButton(variant: PlassVariant.solid, onPressed: () {}, child: const Text('Save')),
PlButton(variant: PlassVariant.glass, onPressed: () {}, child: const Text('Cancel')),
PlButton(
variant: PlassVariant.glass,
color: PlassColor.secondary,
onPressed: () {},
child: const Text('Dismiss'),
),
PlButton(variant: PlassVariant.ghost, onPressed: () {}, child: const Text('Details')),
],
);
}
}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.
import { PlButton } from 'plass-ui';
export default function ButtonColors() {
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<PlButton color="primary">Primary</PlButton>
<PlButton color="secondary">Secondary</PlButton>
<PlButton color="success">Success</PlButton>
<PlButton color="warning">Warning</PlButton>
<PlButton color="danger">Danger</PlButton>
<PlButton color="info">Info</PlButton>
</div>
<div className="flex flex-wrap items-center gap-3">
<PlButton variant="glass" color="primary">
Primary
</PlButton>
<PlButton variant="glass" color="secondary">
Secondary
</PlButton>
<PlButton variant="glass" color="success">
Success
</PlButton>
<PlButton variant="glass" color="warning">
Warning
</PlButton>
<PlButton variant="glass" color="danger">
Danger
</PlButton>
<PlButton variant="glass" color="info">
Info
</PlButton>
</div>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ButtonColors extends StatelessWidget {
const ButtonColors({super.key});
static const Map<PlassColor, String> _roles = <PlassColor, String>{
PlassColor.primary: 'Primary',
PlassColor.secondary: 'Secondary',
PlassColor.success: 'Success',
PlassColor.warning: 'Warning',
PlassColor.danger: 'Danger',
PlassColor.info: 'Info',
};
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (final variant in <PlassVariant>[PlassVariant.solid, PlassVariant.glass])
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
for (final role in _roles.entries)
PlButton(
variant: variant,
color: role.key,
onPressed: () {},
child: Text(role.value),
),
],
),
),
],
);
}
}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.
import { PlButton } from 'plass-ui';
export default function ButtonSizes() {
return (
<div className="flex flex-wrap items-center gap-3">
<PlButton size="xs">Extra small</PlButton>
<PlButton size="sm">Small</PlButton>
<PlButton size="md">Medium</PlButton>
<PlButton size="lg">Large</PlButton>
<PlButton size="xl">Extra large</PlButton>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ButtonSizes extends StatelessWidget {
const ButtonSizes({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
PlButton(size: PlassSize.xs, onPressed: () {}, child: const Text('Extra small')),
PlButton(size: PlassSize.sm, onPressed: () {}, child: const Text('Small')),
PlButton(size: PlassSize.md, onPressed: () {}, child: const Text('Medium')),
PlButton(size: PlassSize.lg, onPressed: () {}, child: const Text('Large')),
PlButton(size: PlassSize.xl, onPressed: () {}, child: const Text('Extra large')),
],
);
}
}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.
import { PlButton } from 'plass-ui';
export default function ButtonDensity() {
return (
<div className="flex flex-wrap items-center gap-3">
<PlButton size="lg">Default</PlButton>
<PlButton size="lg" density="compact">
Compact
</PlButton>
<PlButton size="lg" variant="glass">
Default
</PlButton>
<PlButton size="lg" variant="glass" density="compact">
Compact
</PlButton>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ButtonDensity extends StatelessWidget {
const ButtonDensity({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
PlButton(size: PlassSize.lg, onPressed: () {}, child: const Text('Standard')),
PlButton(
size: PlassSize.lg,
density: PlassDensity.compact,
onPressed: () {},
child: const Text('Compact'),
),
PlButton(
size: PlassSize.lg,
variant: PlassVariant.glass,
onPressed: () {},
child: const Text('Standard'),
),
PlButton(
size: PlassSize.lg,
variant: PlassVariant.glass,
density: PlassDensity.compact,
onPressed: () {},
child: const Text('Compact'),
),
],
);
}
}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.
import { PlButton } from 'plass-ui';
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M8 3.5v9M3.5 8h9" strokeLinecap="round" />
</svg>
);
}
function ArrowIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M3.5 8h9M9 4.5 12.5 8 9 11.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export default function ButtonIcons() {
return (
<div className="flex flex-wrap items-center gap-3">
<PlButton startIcon={<PlusIcon />}>New project</PlButton>
<PlButton variant="glass" endIcon={<ArrowIcon />}>
Continue
</PlButton>
<PlButton aria-label="Add" startIcon={<PlusIcon />} />
<PlButton variant="ghost" aria-label="Add" startIcon={<PlusIcon />} />
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ButtonIcons extends StatelessWidget {
const ButtonIcons({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
PlButton(startIcon: const _Plus(), onPressed: () {}, child: const Text('New project')),
PlButton(
variant: PlassVariant.glass,
endIcon: const _Arrow(),
onPressed: () {},
child: const Text('Continue'),
),
PlButton(semanticLabel: 'Add', startIcon: const _Plus(), onPressed: () {}),
PlButton(
variant: PlassVariant.ghost,
semanticLabel: 'Add',
startIcon: const _Plus(),
onPressed: () {},
),
],
);
}
}
/// A glyph that takes its size and its ink from the button around it.
///
/// `IconTheme` is how a button says "1.2em" to something that is not text — the
/// same job `[&_svg]:size-[1.2em]` does in the React package's stylesheet.
class _Glyph extends StatelessWidget {
const _Glyph(this.draw);
final void Function(Canvas canvas, Paint paint) draw;
@override
Widget build(BuildContext context) {
final theme = IconTheme.of(context);
final size = theme.size ?? 16;
return CustomPaint(
size: Size.square(size),
painter: _GlyphPainter(
draw: draw,
color: theme.color ?? const Color(0xFF000000),
scale: size / 16,
),
);
}
}
class _Plus extends StatelessWidget {
const _Plus();
@override
Widget build(BuildContext context) {
return _Glyph((Canvas canvas, Paint paint) {
canvas
..drawLine(const Offset(8, 3.5), const Offset(8, 12.5), paint)
..drawLine(const Offset(3.5, 8), const Offset(12.5, 8), paint);
});
}
}
class _Arrow extends StatelessWidget {
const _Arrow();
@override
Widget build(BuildContext context) {
return _Glyph((Canvas canvas, Paint paint) {
canvas
..drawLine(const Offset(3.5, 8), const Offset(12.5, 8), paint)
..drawPath(
Path()
..moveTo(9, 4.5)
..lineTo(12.5, 8)
..lineTo(9, 11.5),
paint,
);
});
}
}
class _GlyphPainter extends CustomPainter {
const _GlyphPainter({required this.draw, required this.color, required this.scale});
final void Function(Canvas canvas, Paint paint) draw;
final Color color;
final double scale;
@override
void paint(Canvas canvas, Size size) {
// Drawn in the same 16-unit box the React package's SVGs use, so the two
// are the same drawing rather than two attempts at one.
canvas.save();
canvas.scale(scale);
draw(
canvas,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..color = color,
);
canvas.restore();
}
@override
bool shouldRepaint(_GlyphPainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.scale != scale;
}
}loading · readOnly · disabled
| prop | Appearance | Focus | Native disabled |
|---|---|---|---|
loading | Unchanged; a spinner takes the startIcon slot | Kept | No |
readOnly | Keeps its colour, goes flat, drains saturation | Kept | No |
disabled | Loses the light and the shadow; the page shows through it | Lost | Yes |
| parameter | Appearance | Focus |
|---|---|---|
loading | Unchanged; a spinner takes the startIcon slot | Kept |
readOnly | Keeps its colour, goes flat, drains saturation | Kept |
disabled | Loses the light and the shadow; the page shows through it | Lost |
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.
import { PlButton } from 'plass-ui';
export default function ButtonStates() {
return (
<div className="flex flex-wrap items-center gap-3">
<PlButton>Idle</PlButton>
<PlButton loading>Loading</PlButton>
<PlButton readOnly>Read-only</PlButton>
<PlButton disabled>Disabled</PlButton>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ButtonStates extends StatelessWidget {
const ButtonStates({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
PlButton(onPressed: () {}, child: const Text('Idle')),
PlButton(loading: true, onPressed: () {}, child: const Text('Loading')),
PlButton(readOnly: true, onPressed: () {}, child: const Text('Read-only')),
PlButton(disabled: true, onPressed: () {}, child: const Text('Disabled')),
],
);
}
}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.
import { PlButton } from 'plass-ui';
export default function ButtonElevation() {
return (
<div className="flex flex-wrap items-center gap-3">
<PlButton elevation={0}>Flush</PlButton>
<PlButton elevation={1}>Resting</PlButton>
<PlButton elevation={2}>Raised</PlButton>
<PlButton elevation={3}>Floating</PlButton>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ButtonElevation extends StatelessWidget {
const ButtonElevation({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
PlButton(elevation: 0, onPressed: () {}, child: const Text('Flush')),
PlButton(elevation: 1, onPressed: () {}, child: const Text('Resting')),
PlButton(elevation: 2, onPressed: () {}, child: const Text('Raised')),
PlButton(elevation: 3, onPressed: () {}, child: const Text('Floating')),
],
);
}
}fullWidth
Stretches to the width of the container.
import { PlButton } from 'plass-ui';
export default function ButtonFullWidth() {
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<PlButton fullWidth>Continue</PlButton>
<PlButton fullWidth variant="glass">
Use another account
</PlButton>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class ButtonFullWidth extends StatelessWidget {
const ButtonFullWidth({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
PlButton(fullWidth: true, onPressed: () {}, child: const Text('Continue')),
const SizedBox(height: 12),
PlButton(
fullWidth: true,
variant: PlassVariant.glass,
onPressed: () {},
child: const Text('Use another account'),
),
],
),
);
}
}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>.
import { PlButton } from 'plass-ui';
export default function ButtonRender() {
return (
<div className="flex flex-wrap items-center gap-3">
<PlButton render={<a href="https://plass.cdget.com" />}>Documentation</PlButton>
<PlButton variant="glass" render={<a href="https://plass.cdget.com/components/" />}>
All components
</PlButton>
</div>
);
}Accessibility
- Renders a native
<button>by default.typepasses through, sotype="submit"works inside a form. - Changing the element with
renderkeeps that element's semantics: an<a href>stays a link rather than being covered byrole="button". - Give icon-only buttons an
aria-label. - The focus ring only appears on
:focus-visible, so a mouse click never draws one. loadingandreadOnlykeep 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-motionstops 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 isFocusableActionDetector'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.
loadingandreadOnlykeep 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.
| React | Flutter | Why |
|---|---|---|
render | — | Flutter has no polymorphic element. An action that navigates calls your router from onPressed. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. focusNode, autofocus and onLongPress are offered instead. |
onClick | onPressed | Flutter's name, and onPressed: null disables the button the way it does everywhere else in Flutter. |
children | child | Flutter's name. |
aria-label | semanticLabel | Flutter's name. |
density="default" | PlassDensity.standard | default is a reserved word in Dart. |
prefers-reduced-motion | MediaQuery.disableAnimations | The 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.
glassblurs 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 aglassbutton 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.