Add Toggle and ToggleGroup components
Motivation
Add Toggle and ToggleGroup for persistent pressed-button commands such as bold text, mute, favorites, and optional filters. Standalone toggles should work with local state or app-owned state. Groups should support independent commands and zero-or-one selection, with keyboard navigation available by default.
Apps can build these controls today with Button or ToolbarItem, aria-pressed, and app state. A dedicated pair would supply local toggling, group value management, and a consistent way to compose group focus with an outer toolbar.
Status
This issue body is the canonical design record and implementation specification. Nothing is implemented yet.
Revision 5, updated on 2026-09-04. D01 to D08 were confirmed on 2026-08-31. D09 to D21 were confirmed on 2026-09-04 after an audit that re-read every source claim against current main, re-read the standards the contract depends on, and widened the prior-art comparison from three libraries to ten.
All twenty-one decisions are confirmed. There are no unanswered maintainer questions. Five of the original decisions were amended by the later ones, and each amendment is marked in the table below.
The source-based refinements still need type, framework, browser, and accessibility validation. A finding that would change a confirmed choice must return for a maintainer decision. This issue does not claim that the proposed library behavior has passed those checks.
Usage example
The examples describe the proposed API, not exports that exist today. Accessible labels remain stable when pressed state changes.
Standalone: local or controlled state
// Local state, initially false.
<Toggle>Bold</Toggle>
// Local state, initially true.
<Toggle defaultPressed>Favorite</Toggle>
// Local state, reporting the next value.
<Toggle setPressed={(pressed) => savePreference(pressed)}>Favorite</Toggle>
// App-owned state.
<Toggle
pressed={bold}
onClick={(event) => {
if (event.defaultPrevented) return;
setBold((value) => !value);
}}
>
Bold
</Toggle>setPressed receives the next boolean after an uncancelled activation. onClick still receives the normal event, so a controlled Toggle can run an app command with onClick={toggleBoldCommand} while an editor or other owner supplies pressed.
Single or multiple group values
// string | number | null: zero or one pressed item. The active item can clear.
<ToggleGroup defaultValue={null} aria-label="Message filter">
<Toggle value="unread">Unread</Toggle>
<Toggle value="starred">Starred</Toggle>
</ToggleGroup>
// An array: any number of pressed items, including none.
<ToggleGroup defaultValue={[]} aria-label="Text style">
<Toggle value="bold">Bold</Toggle>
<Toggle value="italic">Italic</Toggle>
<Toggle value="underline">Underline</Toggle>
</ToggleGroup>
// A segmented control that must always have a value.
<ToggleGroup defaultValue="board" unsetValueOnClick={false} aria-label="View">
<Toggle value="list">List</Toggle>
<Toggle value="board">Board</Toggle>
</ToggleGroup>
// Controlled group. Items derive their pressed state from styles.
<ToggleGroup value={styles} setValue={setStyles} aria-label="Text style">
<Toggle value="bold" onClick={maybeCancelBold}>Bold</Toggle>
<Toggle value="italic">Italic</Toggle>
</ToggleGroup>An item handler can call event.preventDefault() to cancel the later group update. It should not also invert the group value, because the group will apply its own update after the handler.
Optional provider and explicit store
const toggle = useToggleStore({
value: styles,
setValue: setStyles,
});
<ToggleProvider store={toggle}>
<ToggleGroup aria-label="Text style">
<Toggle value="bold">Bold</Toggle>
<Toggle value="italic">Italic</Toggle>
</ToggleGroup>
</ToggleProvider>Configure one controlling owner. Do not repeat value, defaultValue, or setValue at the root when a supplied provider or store owns them.
The store is scoped to the Toggle module rather than to groups, in the same way useCheckboxStore serves a lone Checkbox as well as a group. A standalone Toggle can therefore use the store instead of local state when its value must be read or shared.
Compose with an outer Toolbar
<Toolbar aria-label="Editor actions">
<ToolbarItem>Undo</ToolbarItem>
<ToggleGroup defaultValue={[]} aria-label="Inline styles">
<Toggle value="bold">Bold</Toggle>
<Toggle value="italic">Italic</Toggle>
</ToggleGroup>
<ToolbarItem>Redo</ToolbarItem>
</Toolbar>The group finds the Toolbar's composite store through context, so nothing extra is needed here and the items do not have to be rendered as ToolbarItem. Pass the parent store explicitly when context cannot reach it. This composition still requires a library integration probe. See D09 for the mechanism and its open risk.
Requirements
Confirmed decisions
| ID | Decision | Required behavior |
|---|---|---|
| D01 | Selection and optional group focus | One Tab stop and arrow navigation by default. Amended by D09: a group nested in another composite hands focus over through a store reference, not through composite={false}. |
| D02 | Single and multiple pressed buttons | Both modes use a labeled group and buttons with aria-pressed. Amended by D13: single selection can clear by default, and unsetValueOnClick={false} turns that off. Use Radio for exclusive settings that need radio semantics or form participation. |
| D03 | Infer cardinality from scalar or array | Single values are string | number | null; multiple values are ReadonlyArray<string | number>. Default to null. Keep value shape stable after mount. Amended by D11: values are no longer restricted to strings. |
| D04 | Root convenience plus optional provider/store | Root accepts value, defaultValue, and setValue. An explicit store or provider takes ownership. Grouped items derive pressed state. |
| D05 | Boolean state only | No mixed Toggle state in the first release. Mixed editor commands use Button/ToolbarItem with aria-pressed. |
| D06 | Shared core and React first | Reuse Button and Composite. Keep the state contract framework-neutral. Defer a Solid component commitment. |
| D07 | Group callbacks only | Grouped Toggle does not accept setPressed. Use group setValue for state and item onClick for actions or cancellation. |
| D08 | Optional local state | Standalone Toggle accepts pressed, defaultPressed, and onClick. Uncontrolled Toggle toggles internally after an uncancelled click. Amended by D14 and D21: it also accepts setPressed, which receives the next boolean. |
| D09 | Hand focus over with a store reference | The Toggle store accepts a reference to a parent composite store, read from context and overridable by prop. The group stops being focusable when one is present, and each Toggle registers in the parent's collection as well as its own. |
| D10 | Focus defaults follow Toolbar and RadioGroup | Wrap at the ends by default. In single-selection mode, return the active item to the pressed one when focus leaves the group. Multiple-selection mode keeps the last focused item. |
| D11 | Value types follow Radio | Item values are string | number. The empty single-selection value is null; the empty multiple-selection value is []. |
| D12 | Stage the first release | Ship from @ariakit/react-components first. Promote to @ariakit/react once the contract is proven. |
| D13 | A group can require a value | Activating the pressed item can be prevented, so a single-selection group need never be empty. |
| D14 | Local state reports its next value | An uncontrolled Toggle keeps local state and reports the next boolean, so its state is observable without lifting it. |
| D15 | value never reaches the element |
Strip value from the rendered button, the way Radio strips it from non-native elements. A Toggle value identifies group state and is never form data. |
| D16 | Cardinality stays inferred | The value shape decides the mode. A bare ToggleGroup is a single-selection group. Report a development-time error when the shape changes after mount. |
| D17 | Always role="group" |
Both modes keep role="group" with a label. Never emit aria-orientation, which that role does not support. |
| D18 | Public surface | Toggle, ToggleGroup, ToggleProvider, useToggleStore, and matching props and store types. |
| D19 | Component naming | Keep Toggle and ToggleGroup. |
| D20 | unsetValueOnClick, defaulting to true |
A component prop on ToggleGroup and Toggle, not a store option. Setting it to false stops an activation from removing the item's value. |
| D21 | setPressed(nextPressed) |
The standalone state callback receives the next boolean. D07 is unchanged: grouped items still have no state callback. |
Standalone state and activation
These initialization and event details refine D08 using existing repository behavior. Verify them through the public API during implementation.
- Render a native button by default with
type="button"and booleanaria-pressed. Reuse Button's refs,rendercomposition, activation, and disabled behavior. - Treat
pressed !== undefinedas controlled.falseis controlled; an omitted orundefinedvalue permits local state. - Initialize local state with
defaultPressed ?? false. A laterdefaultPressedchange does not reset it. A remount applies the current default. A suppliedpressedvalue takes rendering precedence overdefaultPressed. - In uncontrolled use, invoke caller
onClick, check cancellation, invert local state once, then callsetPressedwith the next boolean. This also works without a caller handler. Do not invent an input or change-event contract. - In controlled use, invoke
onClickand render the app'spressedvalue. An app update the parent rejects must not change committed rendered state. Toggle must not add a second state change that overrides the controlling prop. - App handlers that change controlled state must honor
event.defaultPreventedwhen composed. Cancellation cannot undo a state write already performed. A later bubbling ancestor cannot veto a completed target update. - Do not infer disabled or read-only behavior from the absence of a local handler. An uncontrolled Toggle still toggles; a controlled action may come from a wrapper or ancestor. Use disabled semantics or state text when no action is available.
- Keep ownership stable in first-release examples. Do not promise state transfer when a mounted Toggle enters or leaves a group, or changes between local and controlled ownership. Use remounting as the explicit reset path; validate this boundary without inventing hidden-state transfer rules.
The activation path has two cancellation points in Checkbox, not one, and a Toggle renders a button rather than a native input, so only the click gate applies. Checkbox initialization and controlled precedence, its click cancellation order, and React state initialization are evidence for these refinements. Checkbox semantics and its item-level checked override are not part of this design.
Event prop merging runs the override handler before the base handler and does not stop on defaultPrevented, so the ordering above is something the implementation arranges rather than something merging provides.
Group state and ownership
- Use
nullfor an empty single group and[]for an empty multiple group. Activating the selected single item clears it tonullunlessunsetValueOnClickisfalse; activating another selects that value. Multiple activation adds or removes the item's key. - Item values are unique
stringornumberkeys, matching Radio'svalueprop. Append newly selected multiple keys and remove deselected keys without adding duplicates. Focus order follows the DOM, not array insertion order. - Preserve keys for temporarily unmounted items. Mounting must not choose or clear a value. An unknown controlled key remains state but does not render a pressed item. Duplicate item keys are author errors.
- Resolve one controlling owner: explicit store, then provider, then a root-created store. Configure controlled values and callbacks at that owner. Do not combine a supplied store with a competing
defaultValue. The conflicting-default guard throws in development, fires only when astoreprop is present, and compiles out in production. - Root and provider
setValuereceive a concretestring | number | nullor an array. An imperative store setter may separately accept an updater function. Preserve local store conventions rather than add event-detail objects without a use case. - A grouped Toggle requires a value. Missing value must not silently fall back to local boolean state. Its local
pressedordefaultPressedmust not override group membership. JSX ancestry cannot enforce every misuse, so provide runtime diagnostics and useful explicit-store types. Keep one shared Toggle; do not add ToggleGroupItem solely to work around context typing. - Run item
onClickbefore the group update. If prevented or disabled, do not request selection changes. No groupedsetPressedcallback fires, including for parent changes. unsetValueOnClickdefaults totrueand mirrorssetValueOnClick, including its boolean-or-callback shape. Declare it onToggleGroupand onToggle, so the group sets the policy and an item can override it, the waymoveOnKeyPressis declared on both Composite and CompositeItem. It is a component prop rather than a store option because framework-neutral stores carry noBooleanOrCallbackoptions and no*OnClickoption is a store option.unsetValueOnClickdescribes one activation, so it reads the same in both modes but does different work. In single selection it stops the pressed item clearing. In multiple selection it makes every pressed item one-way, because activating a pressed item no longer removes its key. It is not a minimum-count guarantee, and adding one later would be a separate decision.- Controlled rendered state follows its owner. Do not promise zero transient internal store updates or exactly one callback when an app configures competing linked adapters; those are not supported ownership patterns.
Existing callback types and controlled store synchronization provide implementation precedent. Controlled synchronization re-asserts the owner's value on every batched change, so a rejected update is silently overridden rather than reported.
Focus, disabled behavior, and composition
D01 and D02 settle focus-only arrows and pressed-button semantics. D09 and D10 settle the hand-off and the defaults. The following details follow Composite precedent and require integration validation.
- After hydration, normally expose one enabled Tab stop per focus-managed group.
- Arrows move focus without changing selection. Space and Enter activate. Home and End reach endpoints. Default to horizontal orientation and wrapping, matching Toolbar and Radio, which both set
focusLoop: true. Bare Composite defaults to no wrap, but a ToggleGroup is never a bare Composite. - First entry starts at the first enabled item, independent of pressed state. In single-selection mode, reset the active item to the pressed one when focus leaves the group, the way RadioGroup does on
onBlurCapture, so re-entry lands on the current selection. Multiple-selection mode has no selection to return to and keeps the last focused item. - Right and left arrow direction in right-to-left layouts is a project decision. ARIA says nothing about it and the APG has two open issues on it, so state the chosen convention in the documentation and do not cite a standard for it.
- Preserve native button access before hydration. Every enabled item is its own Tab stop until the collection registers, which the Toolbar pre-hydration fixture demonstrates with JavaScript disabled. If the active item becomes invalid, use Composite's temporary native Tab-order fallback. Do not promise a custom next-then-previous recovery rule.
- Group disable overrides child settings, preserves selected values, and blocks activation. Skip disabled items unless
accessibleWhenDisabledpermits focus. With no focusable disabled items, an all-disabled group has no Tab stop. Do not move focus into the group from outside it. - The APG notes that screen reader users are far less likely to discover disabled elements that are not focusable, because moving focus is one of their primary methods of discovery. That cost is highest in a group with a single Tab stop, so prefer
aria-disabledoverdisabledon items whose presence matters. - The hand-off in D09 follows Tab, the one module already built to be a composite inside another composite. Its store takes a reference to the parent, shares state except for an explicit independence list, stops the container being focusable, and registers each item in the parent's collection as well as its own. Selection, labels, and disabled behavior are untouched. Without an outer owner, keep normal Button Tab order.
- Tab's shipping uses of that pattern are Combobox and Select, which manage focus virtually. Toolbar uses a roving tabindex instead, so which of the two registrations owns
tabIndexis the open risk and needs a probe rather than an argument. If the probe cannot preserve one focus owner, return that evidence before changing D01 or D09. - Do not reuse the name
compositefor a boolean onToggleGroup.CompositeOptions.compositealready means something else, and its documentation states that keyboard navigation on items remains active when it isfalse. - Keep one DOM button per Toggle. Accessible name, refs, cancellation, disabled behavior, and the single focus owner must survive
rendercomposition. Do not promise arbitrary links or native input types as valid render targets.
Composite context and [item keyboard handlers](https://github.com/ariakit/ariakit/blob/d9a
Source: ariakit/ariakit