PlSegmentedButton
Two or more choices in one pill, exactly one of them taken. The tile slides from the segment you left to the one you chose.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
<PlSegmentedButton aria-label="Period" value={period} onValueChange={setPeriod}>
<PlSegment value="day">Day</PlSegment>
<PlSegment value="week">Week</PlSegment>
</PlSegmentedButton>;import 'package:plass_ui/plass_ui.dart';
PlSegmentedButton<String>(
semanticLabel: 'Period',
value: period,
onChanged: (String next) => setState(() => period = next),
segments: const <PlSegment<String>>[
PlSegment<String>(value: 'day', label: Text('Day')),
PlSegment<String>(value: 'week', label: Text('Week')),
],
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | What the groove and the tile riding in it are made of. solid rides a tinted-glass key, glass a clear tile, ghost has no groove at all |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Segment height and type scale — the same ladder as PlButton |
| 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 | 0 | Drop shadow depth of the groove. A groove is cut into the page, so the default is 0 |
| value | string | number | null | — | The chosen segment. Use with onValueChange for a controlled set |
| defaultValue | string | number | null | null | Which starts chosen |
| onValueChange | (value: string | number | null) => void | — | Called with the new value |
| fullWidth | boolean | false | The segments share the full width, each taking an equal part of it |
| readOnly | boolean | false | Shows which one is chosen but does not let it be changed |
| disabled | boolean | false | Disables every segment at once |
| name | string | — | Identifies the value when a form is submitted |
| children | ReactNode | — | The PlSegment children |
| Prop | Type | Default | Description |
|---|---|---|---|
| segments * | List<PlSegment<T>> | — | The choices, as a list of descriptions rather than children — the set owns the roving focus, the arrow keys and the sliding tile |
| value * | T? | — | The chosen segment. Use with onValueChange for a controlled set |
| onChanged | ValueChanged<T>? | — | Called with the new value |
| variantshared | PlassVariant | PlassVariant.glass | What the groove and the tile riding in it are made of. solid rides a tinted-glass key, glass a clear tile, ghost has no groove at all |
| sizeshared | PlassSize | PlassSize.md | Segment height and type scale — the same ladder as PlButton |
| 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 | 0 | Drop shadow depth of the groove. A groove is cut into the page, so the default is 0 |
| fullWidth | bool | false | The segments share the full width, each taking an equal part of it |
| readOnly | bool | false | Shows which one is chosen but does not let it be changed |
| disabled | bool | false | Disables every segment at once |
| semanticLabel | String? | — | The name a screen reader gives the set. It has no visible label of its own |
Every native <div> attribute passes straight through. color is excluded because it collides with the color in the table above, defaultValue and onChange because the set spells them defaultValue (a segment value) and onValueChange.
The set is generic in its segment's type (PlSegmentedButton<String>, PlSegmentedButton<Period>), so value and onChanged are typed rather than dynamic, and it is controlled, like every other control in the package.
PlSegment
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | string | number | — | Identifies the segment. What onValueChange reports |
| startIcon | ReactNode | — | Content before the label. Sized in em, so it tracks the label |
| endIcon | ReactNode | — | Content after the label — a count, a status dot |
| disabled | boolean | false | Unavailable, but still part of the set |
| children | ReactNode | — | The segment's label |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | T | — | Identifies the segment. What onValueChange reports |
| label | Widget? | — | The segment's label |
| startIcon | Widget? | — | Content before the label. Sized in em, so it tracks the label |
| endIcon | Widget? | — | Content after the label — a count, a status dot |
| disabled | bool | false | Unavailable, but still part of the set |
variant, size and density are read from the PlSegmentedButton around the segment, not set on it. A segmented button whose third segment is a size out is not a segmented button.
A segment is a PlSegment, a description rather than a widget, for the reason a radio option is one: the set owns the roving focus, the arrow keys and the tile that slides between the segments, so it has to know which one is taken and where each one is.
It carries no variant, no size and no density, and could not. A segmented button whose third segment is a size out is not a segmented button.
What the shared axes (variant size color density elevation) mean across the library is in prop conventions.
Segmented button, tabs or select
- Segmented button: a handful of short, mutually exclusive choices that filter what is already on screen: a period, a scope, a layout.
- Tabs: the choice swaps whole panels of content.
- Select: more than about five options, or long ones.
Examples
variant
The groove carries --plass-well, the one inset shadow in the library and the same one a solid field is drawn with. Those two are the whole of its use: a groove and a filled field are both a box something sits in. A slider's rail is not one, and no longer takes it. A rail is a line you look along.
solid puts the family's gradient in the tile with that family's tinted shadow under it, which is the design language's own sentence with nothing added: a key of tinted glass riding in a groove. glass and ghost lift a pane of clear glass instead and leave the label in the accent.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
export default function SegmentedButtonVariants() {
return (
<div className="flex flex-col items-start gap-4">
{(['solid', 'glass', 'ghost'] as const).map((variant) => (
<PlSegmentedButton key={variant} variant={variant} aria-label={variant} defaultValue="grid">
<PlSegment value="grid">Grid</PlSegment>
<PlSegment value="list">List</PlSegment>
<PlSegment value="table">Table</PlSegment>
</PlSegmentedButton>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
const List<PlSegment<String>> _views = <PlSegment<String>>[
PlSegment<String>(value: 'grid', label: Text('Grid')),
PlSegment<String>(value: 'list', label: Text('List')),
PlSegment<String>(value: 'table', label: Text('Table')),
];
class SegmentedButtonVariants extends StatelessWidget {
const SegmentedButtonVariants({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final variant in PlassVariant.values)
PlSegmentedButton<String>(
variant: variant,
semanticLabel: variant.name,
segments: _views,
value: 'grid',
onChanged: (String next) {},
),
],
);
}
}color
import { PlSegment, PlSegmentedButton } from 'plass-ui';
export default function SegmentedButtonColors() {
return (
<div className="flex flex-col items-start gap-3">
{(['primary', 'success', 'warning', 'danger'] as const).map((color) => (
<PlSegmentedButton
key={color}
variant="solid"
color={color}
size="sm"
aria-label={color}
defaultValue="on"
>
<PlSegment value="on">On</PlSegment>
<PlSegment value="auto">Auto</PlSegment>
<PlSegment value="off">Off</PlSegment>
</PlSegmentedButton>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
const List<PlSegment<String>> _modes = <PlSegment<String>>[
PlSegment<String>(value: 'on', label: Text('On')),
PlSegment<String>(value: 'auto', label: Text('Auto')),
PlSegment<String>(value: 'off', label: Text('Off')),
];
class SegmentedButtonColors extends StatelessWidget {
const SegmentedButtonColors({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
for (final color in <PlassColor>[
PlassColor.primary,
PlassColor.success,
PlassColor.warning,
PlassColor.danger,
])
PlSegmentedButton<String>(
variant: PlassVariant.solid,
color: color,
size: PlassSize.sm,
semanticLabel: color.name,
segments: _modes,
value: 'on',
onChanged: (String next) {},
),
],
);
}
}size
The same height ladder as PlButton, so a segmented button in a toolbar lines up with the buttons beside it.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
export default function SegmentedButtonSizes() {
return (
<div className="flex flex-col items-start gap-3">
{(['xs', 'sm', 'md', 'lg'] as const).map((size) => (
<PlSegmentedButton key={size} size={size} aria-label={size} defaultValue="a">
<PlSegment value="a">First</PlSegment>
<PlSegment value="b">Second</PlSegment>
</PlSegmentedButton>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
const List<PlSegment<String>> _pair = <PlSegment<String>>[
PlSegment<String>(value: 'a', label: Text('First')),
PlSegment<String>(value: 'b', label: Text('Second')),
];
class SegmentedButtonSizes extends StatelessWidget {
const SegmentedButtonSizes({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
for (final size in PlassSize.values)
PlSegmentedButton<String>(
size: size,
semanticLabel: size.name,
segments: _pair,
value: 'a',
onChanged: (String next) {},
),
],
);
}
}fullWidth
The segments share the row and take an equal part of it each. The tile is re-measured after every layout, so it stays under its segment while the container changes width.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
export default function SegmentedButtonFullWidth() {
return (
<PlSegmentedButton fullWidth aria-label="Delivery" defaultValue="standard" className="max-w-md">
<PlSegment value="standard">Standard</PlSegment>
<PlSegment value="express">Express</PlSegment>
<PlSegment value="pickup">Pick up</PlSegment>
</PlSegmentedButton>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SegmentedButtonFullWidth extends StatefulWidget {
const SegmentedButtonFullWidth({super.key});
@override
State<SegmentedButtonFullWidth> createState() => _SegmentedButtonFullWidthState();
}
class _SegmentedButtonFullWidthState extends State<SegmentedButtonFullWidth> {
String _delivery = 'standard';
@override
Widget build(BuildContext context) {
return SizedBox(
width: 448,
child: PlSegmentedButton<String>(
fullWidth: true,
semanticLabel: 'Delivery',
value: _delivery,
onChanged: (String next) => setState(() => _delivery = next),
segments: const <PlSegment<String>>[
PlSegment<String>(value: 'standard', label: Text('Standard')),
PlSegment<String>(value: 'express', label: Text('Express')),
PlSegment<String>(value: 'pickup', label: Text('Pick up')),
],
),
);
}
}startIcon and endIcon
Both are sized against the label rather than against the row. An icon-only segment still needs a name of its own.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
function GridIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1" />
<rect x="9" y="2.5" width="4.5" height="4.5" rx="1" />
<rect x="2.5" y="9" width="4.5" height="4.5" rx="1" />
<rect x="9" y="9" width="4.5" height="4.5" rx="1" />
</svg>
);
}
function ListIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M5 4h9M5 8h9M5 12h9M2.5 4h.01M2.5 8h.01M2.5 12h.01" strokeLinecap="round" />
</svg>
);
}
export default function SegmentedButtonIcons() {
return (
<PlSegmentedButton aria-label="Layout" defaultValue="grid">
<PlSegment value="grid" startIcon={<GridIcon />}>
Grid
</PlSegment>
<PlSegment value="list" startIcon={<ListIcon />} endIcon={<span>12</span>}>
List
</PlSegment>
</PlSegmentedButton>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
/// Four squares, and three rules with a bullet — the two layout glyphs.
class _LayoutGlyph extends StatelessWidget {
const _LayoutGlyph({required this.grid});
final bool grid;
@override
Widget build(BuildContext context) {
final theme = IconTheme.of(context);
return CustomPaint(
size: Size.square(theme.size ?? 16),
painter: _LayoutPainter(grid: grid, color: theme.color ?? const Color(0xFF000000)),
);
}
}
class _LayoutPainter extends CustomPainter {
const _LayoutPainter({required this.grid, required this.color});
final bool grid;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..strokeCap = StrokeCap.round
..color = color;
canvas
..save()
..scale(size.shortestSide / 16);
if (grid) {
for (final origin in const <Offset>[
Offset(2.5, 2.5),
Offset(9, 2.5),
Offset(2.5, 9),
Offset(9, 9),
]) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(origin.dx, origin.dy, 4.5, 4.5),
const Radius.circular(1),
),
paint,
);
}
} else {
for (final y in const <double>[4, 8, 12]) {
canvas
..drawLine(Offset(5, y), Offset(14, y), paint)
..drawLine(Offset(2.5, y), Offset(2.6, y), paint);
}
}
canvas.restore();
}
@override
bool shouldRepaint(_LayoutPainter oldDelegate) {
return oldDelegate.grid != grid || oldDelegate.color != color;
}
}
class SegmentedButtonIcons extends StatefulWidget {
const SegmentedButtonIcons({super.key});
@override
State<SegmentedButtonIcons> createState() => _SegmentedButtonIconsState();
}
class _SegmentedButtonIconsState extends State<SegmentedButtonIcons> {
String _layout = 'grid';
@override
Widget build(BuildContext context) {
return PlSegmentedButton<String>(
semanticLabel: 'Layout',
value: _layout,
onChanged: (String next) => setState(() => _layout = next),
segments: const <PlSegment<String>>[
PlSegment<String>(value: 'grid', startIcon: _LayoutGlyph(grid: true), label: Text('Grid')),
PlSegment<String>(
value: 'list',
startIcon: _LayoutGlyph(grid: false),
label: Text('List'),
endIcon: Text('12'),
),
],
);
}
}Accessibility
- The set is a
role="radiogroup"and each segment is a real radio, which is the whole accessibility argument: a segmented button is "exactly one of these". Built out ofaria-pressedtoggles it would announce four independent switches, three of which happen to be off. - One tab stop for the whole set; ← → ↑ ↓ move within it. Base UI owns the roving tab index.
- Give the set an
aria-label. It has no visible label of its own, and a group with no name is a group a screen reader announces as "radio group".
- Each segment is announced as one of a mutually exclusive set, taken or not. A segmented button is "exactly one of these". Built out of toggles it would announce four independent switches, three of which happen to be off.
- One focus stop for the whole set: exactly one segment is in the tab order and the rest are wrapped in an
ExcludeFocus. ← → ↑ ↓ move the choice, wrapping at both ends. - A segment's focus ring turns inward, because a ring drawn outside one inside a groove would be painted over its neighbours.
- Give the set a
semanticLabel. It has no visible label of its own.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
<PlSegment> children | segments, as descriptions | The set owns the roving focus, the arrow keys and the sliding tile, so it has to know which one is taken and where each one is. |
defaultValue / onValueChange | value / onChanged | Flutter's own controls are controlled, and its name for the callback. |
a value of string | number | a generic T | Dart has generics, so the type is checked rather than restrained by convention. |
| four CSS custom properties on the tile | a measured Rect and an AnimatedPositioned | The same idea (measure the chosen segment, animate the box) in Flutter's words. Nothing is transformed either way. |
aria-label | semanticLabel | Flutter's name. |
name, and a hidden input | — | There is no native form submission to be part of. |
- The focus ring is drawn inset, because an offset ring on a segment inside a groove would be painted over its neighbours.
- The tile animates
left,top,widthandheightrather than atransform: it is an empty box, so no label is resampled while it travels. That is what lets the house no-transform rule survive a component whose entire point is that something moves. - The first choice of an empty set appears in place rather than flying in from the left edge. The tile is not mounted until there is something to sit under.