Pick one of a few short options, all in view, that stacks when its row does not fit.
Basic
Pass a label, and the options as children, each with its own value. The chosen option is the ink pill. Use it for a few short options that are all worth seeing at once. For more, or for options that need explaining, use a list.
Example
Example
'use client';
import { SegmentedControl, SegmentedControlItem } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function SegmentedControlBasic(): ReactElement {
return (
<SegmentedControl
label="Appearance"
description="System follows the setting of the device."
defaultValue="system"
style={{ width: '100%', maxWidth: '28rem' }}
>
<SegmentedControlItem value="light">Light</SegmentedControlItem>
<SegmentedControlItem value="dark">Dark</SegmentedControlItem>
<SegmentedControlItem value="system">System</SegmentedControlItem>
</SegmentedControl>
);
}States
The track's edge carries the state, as in every field.
- Changed: a light signal orange edge and a clay chip beside the label.
- Invalid: the error edge and an error line. Invalid outranks changed, so the chip steps aside.
- Disabled: recessed, with the chosen option kept in view as a quiet taupe pill.
- One disabled option: set
isDisabledon its item, and the rest stay open. - Compact:
size="sm"draws 32px options in a 40px track, still hit at 40px.
Example
Example
'use client';
import { SegmentedControl, SegmentedControlItem } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const PLANS = (
<>
<SegmentedControlItem value="free">Free</SegmentedControlItem>
<SegmentedControlItem value="team">Team</SegmentedControlItem>
<SegmentedControlItem value="business">Business</SegmentedControlItem>
</>
);
export default function SegmentedControlStates(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '28rem' }}>
<SegmentedControl label="Billing plan" defaultValue="team" isChanged>
{PLANS}
</SegmentedControl>
<SegmentedControl
label="Plan for the new workspace"
isInvalid
errorMessage="Choose a plan to continue."
>
{PLANS}
</SegmentedControl>
<SegmentedControl label="Plan while billing is paused" defaultValue="business" isDisabled>
{PLANS}
</SegmentedControl>
<SegmentedControl label="Plan for a trial" defaultValue="free">
<SegmentedControlItem value="free">Free</SegmentedControlItem>
<SegmentedControlItem value="team">Team</SegmentedControlItem>
<SegmentedControlItem value="business" isDisabled>
Business
</SegmentedControlItem>
</SegmentedControl>
<SegmentedControl aria-label="Corners" size="sm" defaultValue="soft">
<SegmentedControlItem value="rounded">Rounded</SegmentedControlItem>
<SegmentedControlItem value="soft">Soft</SegmentedControlItem>
<SegmentedControlItem value="square">Square</SegmentedControlItem>
</SegmentedControl>
</div>
);
}Stacking
The options sit all in one row or all in a column, never a mix. When the row does not fit, the control stacks one 40px option per row, and returns to a row when the room comes back.
The control compares its row with its own width, so give it a column to fill, as you would a text field.
- Content-sized: a control sized to its content, such as in a flex row or a centered grid, has nothing to compare with and never stacks.
- Before hydration: stacking is decided in the browser, so before hydration or with scripts off, a row that does not fit overflows its root.
- Spacing: add spacing outside the control, as a margin or a wrapper. Horizontal padding on the root through
classNamemakes the measured room wider than the track's.
Example
Example
'use client';
import { SegmentedControl, SegmentedControlItem } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
function Cadence({ label }: { label: string }): ReactElement {
return (
<SegmentedControl label={label} defaultValue="weekly">
<SegmentedControlItem value="daily">Daily</SegmentedControlItem>
<SegmentedControlItem value="weekly">Weekly</SegmentedControlItem>
<SegmentedControlItem value="monthly">Monthly</SegmentedControlItem>
<SegmentedControlItem value="quarterly">Quarterly</SegmentedControlItem>
<SegmentedControlItem value="yearly">Yearly</SegmentedControlItem>
</SegmentedControl>
);
}
export default function SegmentedControlStacking(): ReactElement {
return (
<div className="stack" style={{ width: '100%', gap: 32 }}>
<div style={{ width: '100%', maxWidth: '40rem' }}>
<Cadence label="Report cadence in a wide room" />
</div>
<div style={{ width: '100%', maxWidth: '18rem' }}>
<Cadence label="Report cadence in a narrow room" />
</div>
</div>
);
}Usage
Every value works controlled and uncontrolled, and a control with a name submits the chosen value in a plain form. onChange receives the value. A value of null is a controlled control with nothing chosen.
import { useState } from 'react';
import { SegmentedControl, SegmentedControlItem } from '@snapshot-ui/react';
export function Appearance({ saved }: { saved: string }) {
const [mode, setMode] = useState(saved);
return (
<SegmentedControl
label="Appearance"
name="appearance"
value={mode}
onChange={setMode}
isChanged={mode !== saved}
>
<SegmentedControlItem value="light">Light</SegmentedControlItem>
<SegmentedControlItem value="dark">Dark</SegmentedControlItem>
<SegmentedControlItem value="system">System</SegmentedControlItem>
</SegmentedControl>
);
}Without a visible label, name the control with aria-label or aria-labelledby, such as a toolbar switch named by what it sits beside. The types require one of the three. Every option takes children. An icon-only option passes the icon there and adds an aria-label.
Props
Every React Aria RadioGroup prop is accepted except orientation, since the options always start in a row and stack on their own. These are the ones Snapshot UI adds or shapes.
aria-label string | Defines a string value that labels the current element. |
|---|---|
changedLabel string | The text of the Changed chip. Default: 'Changed' |
defaultValue string | null | The default value (uncontrolled). |
description ReactNode | A hint under the control. It becomes the group's accessible description. |
errorMessage ((validation: ValidationResult) => ReactNode) | ReactNode | The error line, shown only while the control is invalid. A function receives the validation result. Without it the control shows its own validation messages. |
isChanged boolean | Whether the value differs from the saved one. Shows the clay Changed chip and the light signal orange edge, and a screen reader reads the chip with the control. An invalid control shows its error instead. |
isDisabled boolean | Whether the input is disabled. |
isInvalid boolean | Whether the input value is invalid. |
isReadOnly boolean | Whether the input can be selected but not changed by the user. |
isRequired boolean | Whether user input is required on the input before form submission. |
label ReactNode | The control's name, shown above it with the Changed chip. Required unless the control has an
|
name string | The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). |
onChange ((value: string) => void) | Handler that is called when the value changes. |
size SegmentedControlSize |
Default: 'md' |
validate ((value: string) => ValidationError | true | null | undefined) | A function that returns an error message if a given value is invalid.
Validation errors are displayed to the user when the form is submitted
if |
value string | null | The current value (controlled). |
Each option is a SegmentedControlItem.
children ReactNode | What the option shows: a word or two. An option that shows only an icon passes the icon here and
names itself with an |
|---|---|
className string | ((values: RadioButtonRenderProps & { defaultClassName: string | undefined; }) => string) | A class name, or a function of the option's state, merged after the library's own class. |
isDisabled boolean | Whether the radio button is disabled or not. Shows that a selection exists, but is not available in that circumstance. |
value string | The value the control takes while this option is selected. It is also what a form submits. |
Accessibility
A native radio group: each option is a radio, the label names the group, and React Aria handles the wiring. While the control is changed and valid, a screen reader reads the Changed chip after the hint. An invalid control reads its error line alone. The group is one tab stop, and focus lands on the chosen option.
| Tab | Moves focus into the control, onto the chosen option, or the first when nothing is chosen. |
|---|---|
| RightDown | Chooses the next option, and wraps from the last to the first. |
| LeftUp | Chooses the previous option, and wraps from the first to the last. |
| Space | Chooses the focused option. |
- Focus ring: 2px, outside an unchosen option. On the chosen ink option it is drawn inside, in the canvas color.
- Disabled option: skipped by the arrow keys.
- Read-only: keeps its choice and shows no hover wash.
- Never disable the chosen option of a control the reader can still change. React Aria gives the tab stop only to the chosen option and takes it from a disabled one, so Tab skips the control.
- Missing name: the control does not compile. TypeScript either names only
aria-labelledbyor says the props are not assignable. Both mean the control has no accessible name.
Design rules
What keeps segmented controls in the system.
Use two to five options of a word or two each, all worth seeing at once.
Name the control in the label, in sentence case.
Show the chip and the changed edge together, and only while the value is unsaved.
Never use it to switch what a page shows. That is tabs, and they hold no value.
Never let the options wrap onto a second row of their own. The control stacks them or it does not.
Never leave the chip beside an error. Invalid outranks changed.