#7353·ariakit

Add Toggle and ToggleGroup components

Author: diegohazCreated Aug 31, 2026Updated Sep 4, 2026
Labelsfeature

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

typescript
// 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

typescript
// 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

typescript
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

typescript
<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 boolean aria-pressed. Reuse Button's refs, render composition, activation, and disabled behavior.
  • Treat pressed !== undefined as controlled. false is controlled; an omitted or undefined value permits local state.
  • Initialize local state with defaultPressed ?? false. A later defaultPressed change does not reset it. A remount applies the current default. A supplied pressed value takes rendering precedence over defaultPressed.
  • In uncontrolled use, invoke caller onClick, check cancellation, invert local state once, then call setPressed with the next boolean. This also works without a caller handler. Do not invent an input or change-event contract.
  • In controlled use, invoke onClick and render the app's pressed value. 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.defaultPrevented when 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 null for an empty single group and [] for an empty multiple group. Activating the selected single item clears it to null unless unsetValueOnClick is false; activating another selects that value. Multiple activation adds or removes the item's key.
  • Item values are unique string or number keys, matching Radio's value prop. 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 a store prop is present, and compiles out in production.
  • Root and provider setValue receive a concrete string | number | null or 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 pressed or defaultPressed must 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 onClick before the group update. If prevented or disabled, do not request selection changes. No grouped setPressed callback fires, including for parent changes.
  • unsetValueOnClick defaults to true and mirrors setValueOnClick, including its boolean-or-callback shape. Declare it on ToggleGroup and on Toggle, so the group sets the policy and an item can override it, the way moveOnKeyPress is declared on both Composite and CompositeItem. It is a component prop rather than a store option because framework-neutral stores carry no BooleanOrCallback options and no *OnClick option is a store option.
  • unsetValueOnClick describes 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 accessibleWhenDisabled permits 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-disabled over disabled on 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 tabIndex is 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 composite for a boolean on ToggleGroup. CompositeOptions.composite already means something else, and its documentation states that keyboard navigation on items remains active when it is false.
  • Keep one DOM button per Toggle. Accessible name, refs, cancellation, disabled behavior, and the single focus owner must survive render composition. 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