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

AppearanceChanged
System follows the setting of the device.
Follows the page theme and corners
'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 isDisabled on its item, and the rest stay open.
  • Compact: size="sm" draws 32px options in a 40px track, still hit at 40px.

Example

Billing planChanged
Plan for the new workspace
Choose a plan to continue.
Plan while billing is pausedChanged
Plan for a trialChanged
Follows the page theme and corners
'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 className makes the measured room wider than the track's.

Example

Report cadence in a wide roomChanged
Report cadence in a narrow roomChanged
Follows the page theme and corners
'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 aria-label or an aria-labelledby.

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

md is a 48px track of 40px options. sm is a 40px track whose 32px options are still hit at 40px, for a toolbar or a header.

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 validationBehavior="native". For realtime validation, use the isInvalid prop instead.

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 aria-label.

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.

TabMoves focus into the control, onto the chosen option, or the first when nothing is chosen.
RightDownChooses the next option, and wraps from the last to the first.
LeftUpChooses the previous option, and wraps from the first to the last.
SpaceChooses 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-labelledby or 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.

esc

↑ ↓ to move↵ to open