Skip to main content

ButtonGroup

betav0.1.0

Segmented control: a run of icon-only buttons sharing one selected value, for switching between mutually exclusive views of the same data (cards / lines / graph). Single-select by construction. Compound: <ButtonGroup> owns the selection and the roving focus, <ButtonGroupItem> is one segment. Corner rounding follows DOM position automatically — there is no position prop, so conditionally rendered items never leave a stale rounded edge; `corner` picks WHICH outer edges are rounded (`rounded` both, `square` none, `end` the trailing edge only, for a run embedded at the right of a SearchField). `usecase` picks how the run sits on its ground: `integrated` (default) fills every segment, `isolated` draws the run as an outline with no fill. Not for actions: two buttons that do two different things are two Buttons, not a group.

Variants

Sizes — md (36px) and sm (24px)

Corner — rounded (default), square, and end (trailing edge only, for a run embedded at the right of a SearchField)

Use case — integrated (default) fills every segment; isolated draws the run as an outline with no fill (frame 6590:39641)

One item disabled — arrow keys skip it

Whole group disabled — the selected segment keeps a neutral veil rather than the brand wash, so a disabled control never carries the brand colour (node 7363:20967)

A run that NAVIGATES (`asChild`, LIBRARY-FEEDBACK #56) — each segment is the consumer's own anchor, so the href is real: hover one to see the target in the status bar, and middle-click to open it in a tab. The group still owns selection and roving focus. The third segment is disabled: it carries `aria-disabled` rather than the button-only `disabled` attribute, and neither clicks through nor takes arrow focus.

Two items — the run still rounds its outer edges only

Examples

<ButtonGroup value={view} onValueChange={setView} aria-label="View mode">
  <ButtonGroupItem value="cards" aria-label="Cards view" icon={<Icon name="layout-grid" size={16} />} />
  <ButtonGroupItem value="lines" aria-label="Lines view" icon={<Icon name="list" size={16} />} />
</ButtonGroup>
// dense toolbar
<ButtonGroup size="sm" value={mode} onValueChange={setMode} aria-label="Display mode">…</ButtonGroup>
// an item can carry its own handler as well as the group's
<ButtonGroupItem value="export" aria-label="Export" onClick={openExport} icon={<Icon name="download" size={16} />} />
// conditional items are safe — rounding follows the rendered DOM
{canGraph && <ButtonGroupItem value="graph" aria-label="Graph view" icon={<Icon name="share-2" size={16} />} />}
// a run that navigates — the segment IS the router link (LIBRARY-FEEDBACK #56)
<ButtonGroup value={tab} aria-label="Content view">
  <ButtonGroupItem asChild value="content" aria-label="Content" icon={<Icon name="file-text" size={16} />}>
    <Link to="" />
  </ButtonGroupItem>
  <ButtonGroupItem asChild value="editor" aria-label="Editor" icon={<Icon name="pencil" size={16} />}>
    <Link to="editor" />
  </ButtonGroupItem>
</ButtonGroup>

Props

PropType
ButtonGroup.valuestring
ButtonGroup.onValueChange(value: string) => void
ButtonGroup.size"sm" | "md"
ButtonGroup.disabledboolean
ButtonGroup.corner"rounded" | "square" | "end"
ButtonGroup.usecase"integrated" | "isolated" — "integrated" (default) fills every segment; "isolated" draws the run as an outline with no fill. On the GROUP, not the item
ButtonGroup.aria-labelstring
ButtonGroupItem.valuestring (required)
ButtonGroupItem.iconReactNode (required, 16×16)
ButtonGroupItem.aria-labelstring (required)
ButtonGroupItem.disabledboolean
ButtonGroupItem.asChildboolean — renders the child as the segment (Radix Slot), for a run that NAVIGATES rather than switching a value: `<ButtonGroupItem asChild …><Link to="editor" /></ButtonGroupItem>`. The group keeps owning selection and roving focus — the child is cloned with role="radio", aria-checked, data-value and the roving tabIndex — and the anchor keeps its href, so middle-click, open-in-new-tab and the hover target survive. `type` and `disabled` are never emitted on a non-button; a disabled segment carries aria-disabled and is skipped by the arrow keys. NOTE: under role="radio" a screen reader announces "radio button" where MUI's ToggleButton leaves the anchor announced as "link" — see ButtonGroup.rfc.md §9 Q9, open for design arbitration. Added for LIBRARY-FEEDBACK #56
ButtonGroupItem.childrenReactNode — the element to render as the segment; REQUIRED with `asChild`, meaningless without it. Its own content is REPLACED by `icon`, exactly as on IconButton

Accessibility

2.1 AAWCAG fail

role=radiogroup on the container, role=radio + aria-checked on each item — a segmented control is a single-choice picker, which is what radiogroup describes; tablist would promise a panel association this component does not provide. Roving tabindex: the group is ONE tab stop, entered at the selected item (or the first enabled one when nothing is selected yet). Arrow keys move and select (selection follows focus, the WAI-ARIA radiogroup pattern), Home/End jump to the ends, and navigation wraps. Disabled items are skipped by arrow navigation and are never the entry point. Focus order is computed from the DOM ([role=radio]) rather than from React children, because products wrap almost every item in a Tooltip — React.Children would see the wrapper. aria-label is a REQUIRED prop on the item: it renders no text, so omitting it produces an unnameable control. The focus ring is the repo-standard ring-2/ring-offset-2 rather than Figma's 1px-offset/3px shadow — same visual weight, one recipe across the library — and it carries z-10 so the ring paints above the flush neighbouring segments instead of under them. Disabled fills (--bg-input-disabled / --icon-disabled) are exempt from contrast (WCAG 1.4.3 Note 1, inactive UI components) and are deliberately not declared above — including SELECTED + DISABLED (node 7363:20967), where the brand wash and its border give way to a neutral 70% veil of the page ground and no border. Exempt outright, so no dated entry: measured at glyph 6.03:1 dark / 4.81:1 light on the veiled fill, 1.39 / 1.30 against a plain disabled neighbour. wcagStatus is "fail" and truthfully so: the selected segment reads 1.23:1 dark / 1.39:1 light against its unselected neighbour, below the 3:1 WCAG 1.4.11 asks for a state distinction. Accepted as drawn by the design lead on 2026-08-26 with the improvement deferred to V2 — entry in scripts/wcag-allowlist.json. The node draws the Active border as decorative (transparency-10), so it carries none of the identification, and raising the wash was measured and declined: the only alpha that clears the fill pair is the one where the brand glyph stops clearing on it. What identifies the control otherwise all passes, and aria-checked carries the state unambiguously to assistive technology — the shortfall is the visual-only distinction between two adjacent segments. Focus is an ACCEPTED DIVERGENCE: the node draws a 2px border hugging the tile, the implementation uses the library-wide ring recipe so the whole system shares one focus treatment. asChild (2026-08-30, LIBRARY-FEEDBACK #56) — a segment can be the consumer's own anchor or router Link, and it stays a full member of the group: the child is cloned with role, aria-checked, data-value and the roving tabIndex, so nothing above changes. Two button-only attributes are withheld from a non-button, and the second one matters more than it looks: `disabled` on an <a> is an invalid attribute that assistive tech ignores AND that the cva's `disabled:` classes never match, so the segment would look disabled while staying operable. A disabled asChild segment carries aria-disabled, leaves the roving order, and has click and keydown suppressed explicitly — aria-disabled suppresses nothing on its own. ONE THING THIS PROP CANNOT PRESERVE, stated because a product will read this before adopting: the ANNOUNCED ROLE. Under role="radio" a screen reader says "radio button, checked" where the MUI code being replaced leaves the anchor announced as "link" — verified in the installed @mui/material source, where ToggleButton sets aria-pressed and no role while only ToggleButtonGroup sets role="group". Every POINTER and BROWSER affordance is kept (href, middle-click, open-in-new-tab, hover target, Ctrl/Cmd-click) and Enter activation is the anchor's own. Whether a navigating run should instead be a role="group" of plain links marked with aria-current is a design arbitration, open in ButtonGroup.rfc.md §9 Q9 and deliberately unresolved in code.