Skip to content

Collection controls

Four controls for choosing among a set, moving through a set, editing a set, and showing where you are in one: ToggleGroup, Tabs, TagGroup and Breadcrumbs. Each is a thin Cratis surface over its React Aria equivalent, so the keyboard behaviour and the roles come from the foundation and the appearance is entirely yours through parts.

These two look alike and mean different things, so pick by what the choice does:

ToggleGroupTabs
The choice isa value — a period, a mode, a filtera view — which panel of content is shown
Rolesradiogroup / radiotablist / tab / tabpanel
Arrow keysmove focus; Space or Enter commitsmove the selection, and the panel follows
Belongs in a formyes — it is an inputno

The distinction matters to a screen-reader user, who is told which of the two they have. A segmented control built out of tablist announces a panel switcher that switches nothing.

ToggleGroup’s arrow keys move focus rather than selecting, because a segmented control often drives a query: arrowing from Day to Month must not run the month query on the way past.

<ToggleGroup
options={[
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]}
value={period}
onChange={setPeriod}
aria-labelledby='period-label'
/>

A group has no single labelable element, so an external <label> associates with aria-labelledby rather than htmlFor. invalid and aria-describedby wire it to a field’s error text.

<Tabs
tabs={[
{ id: 'open', label: 'Open', content: <OpenRequests /> },
{ id: 'closed', label: 'Closed', content: <ClosedRequests /> },
]}
value={tab}
onChange={setTab}
aria-label='Requests'
/>

Every tab owns a panel and is wired to it through aria-controls. That is why content is required: the selected tab names its panel, so a tab set without panels would point at an element that is not there. When the choice drives a view rendered elsewhere it is a value, not a panel — use ToggleGroup, which tells assistive technology exactly that.

A group of removable values with an optional text entry — the control behind a tag or chips field.

<TagGroup
value={skills}
onChange={setSkills}
placeholder='Add a skill…'
removeLabel={(skill) => `Remove ${skill}`}
aria-labelledby='skills-label'
/>
KeyDoes
Enter, or any configured separators charactercommits the typed value
Backspace on an empty entryremoves the last value
Arrow keys on a tagmoves between tags
Backspace or Delete on a tagremoves that tag
paste containing a separatorsplits and adds each value

Duplicates are refused unless allowDuplicates is set. editable={false} drops the entry and leaves a read-only set of removable tags. Give removeLabel a localized builder — the remove buttons are the one place this control needs words of its own — and removeIcon your icon set’s close icon, which replaces the default × without touching the button or its accessible name.

<Breadcrumbs
aria-label='Breadcrumb'
items={[
{ label: 'Requests', href: '/requests' },
{ label: 'Equinor ASA', href: '/requests/equinor' },
{ label: 'FRP-1284' },
]}
/>

The last item is the current page: it carries aria-current="page", has no destination and is out of the tab order. That is what tells a screen-reader user where the trail ends, and it is why every segment renders as a link — React Aria hands the current state down to the last one, and a segment rendered as plain text would lose it.

Pass onNavigate instead of href when a router owns navigation. separator takes any decorative node and defaults to a slash; separators are hidden from assistive technology either way.

Every control is styled through its parts — see the pass-through cheat sheet:

ControlParts
ToggleGrouproot, option, icon, label
Tabsroot, list, tab, panel
TagGrouproot, list, tag, remove, input
Breadcrumbsroot, item, link, separator

The selected option, tab, current breadcrumb and their disabled/invalid states are exposed as data-selected, data-disabled and data-invalid, so a stylesheet never needs to know the component’s internals.