A set of toggle buttons that share one value: any number pressed by default, at most one with exclusive. Every Item is the Toggle element (<button aria-pressed>); the group is one Tab stop with arrow keys between the Items, and inside a Toolbar its Items join the toolbar's sequence.
"use client";
import { useState } from "react";
import { ToggleGroup } from "@funstack/ui/toggle-group";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";
// Multiple mode, uncontrolled, with an async save. Each Item flips at once and shows a spinner
// (data-pending) until its save settles. With "fail" on, the save rejects: the Item flips back
// and the error reaches the boundary.
export function ToggleGroupStyles() {
const [fail, setFail] = useState(false);
return (
<div className="demo-stack">
<label>
<input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} /> Make
the save fail
</label>
<DemoErrorBoundary>
<ToggleGroup.Root
aria-label="Text style"
className="demo-toggle-group"
defaultValue={["bold"]}
valueChangeAction={(next, { added, removed }) =>
fakeSave(`styles (added ${added}, removed ${removed}) → ${next}`, { fail })
}
>
{["Bold", "Italic", "Underline"].map((label) => (
<ToggleGroup.Item key={label} value={label.toLowerCase()} className="demo-toggle-item">
{({ pending }) => (
<>
{pending && <span className="demo-spinner" aria-hidden />}
{label}
</>
)}
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
</DemoErrorBoundary>
</div>
);
}/* ToggleGroup: data attributes carry library state; :focus-visible comes from the platform. */
.demo-toggle-group {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.demo-toggle-group[data-orientation="vertical"] {
flex-direction: column;
}
.demo-toggle-group[data-pending] {
cursor: progress;
}
.demo-toggle-item {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.3em 0.8em;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
/* The optimistic flip is a sync commit: a plain CSS transition animates it. */
transition:
background-color 120ms,
color 120ms;
}
.demo-toggle-item[data-pressed] {
background: var(--color-primary);
color: var(--color-on-primary);
}
.demo-toggle-item[data-pending] {
cursor: progress;
}
.demo-toggle-item[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.demo-toggle-item:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.demo-toggle-item {
transition: none;
}
}Each Item flips the moment it is clicked and carries data-pending until its save settles; the group carries it too. A rejected save flips the Item back and reaches the Error Boundary around the group. Name the group with aria-label or aria-labelledby: it is what tells screen-reader users the buttons belong together.
Alignment:
Layout:
"use client";
import { startTransition, useState } from "react";
import { ToggleGroup } from "@funstack/ui/toggle-group";
import { fakeSave } from "./fake-api.js";
// Exclusive mode. The first group allows "nothing pressed" (press Left again to release it);
// the second is a view switcher with deselectable={false}: pressing the pressed Item does
// nothing. Controlled, "update on success": the setState after the await is a transition.
export function ToggleGroupAlignment() {
const [align, setAlign] = useState<string | null>("left");
const [layout, setLayout] = useState<string | null>("grid");
return (
<div className="demo-stack">
<ToggleGroup.Root
exclusive
aria-label="Text alignment"
className="demo-toggle-group"
value={align}
valueChangeAction={async (next) => {
await fakeSave(`alignment=${next}`);
startTransition(() => setAlign(next));
}}
>
<ToggleGroup.Item value="left" className="demo-toggle-item">
Left
</ToggleGroup.Item>
<ToggleGroup.Item value="center" className="demo-toggle-item">
Center
</ToggleGroup.Item>
<ToggleGroup.Item value="right" className="demo-toggle-item">
Right
</ToggleGroup.Item>
</ToggleGroup.Root>
<p>
Alignment: <output>{align ?? "none"}</output>
</p>
<ToggleGroup.Root
exclusive
deselectable={false}
aria-label="Layout"
className="demo-toggle-group"
value={layout}
valueChangeAction={(next) => {
setLayout(next);
}}
>
<ToggleGroup.Item value="grid" className="demo-toggle-item">
Grid
</ToggleGroup.Item>
<ToggleGroup.Item value="list" className="demo-toggle-item">
List
</ToggleGroup.Item>
</ToggleGroup.Root>
<p>
Layout: <output>{layout}</output>
</p>
</div>
);
}With exclusive, value is string | null. Pressing the pressed Item releases it (next === null); a view switcher that must always show one value passes deselectable={false}, and pressing the pressed Item then dispatches nothing.
next is the whole set; details.added and details.removed say what changed. When you update after your own await, apply the diff with a functional update: two overlapping saves that settle out of order then cannot lose a change.
const [styles, setStyles] = useState<string[]>(doc.styles);
<ToggleGroup.Root
aria-label="Text style"
value={styles}
valueChangeAction={async (_next, { added, removed }) => {
await api.patchStyles(doc.id, { added, removed });
startTransition(() =>
setStyles((prev) => [
...prev.filter((v) => !removed.includes(v)),
...added.filter((v) => !prev.includes(v)),
]),
);
}}
>
…
</ToggleGroup.Root>A slow valueChangeAction holds every other transition in the app until it settles; for a fire-and-forget save, call void save(next) without awaiting it.
import { ToggleGroup } from "@funstack/ui/toggle-group";
<ToggleGroup.Root aria-label="Text style" defaultValue={["bold"]} valueChangeAction={save}>
<ToggleGroup.Item value="bold">Bold</ToggleGroup.Item>
<ToggleGroup.Item value="italic">Italic</ToggleGroup.Item>
</ToggleGroup.Root>| Part | Element | Role |
|---|---|---|
ToggleGroup.Root | <div role="group"> | Owns the value, the transition and the roving focus. |
ToggleGroup.Item | <button type="button" aria-pressed> | One toggle; its pressed state is the Root's. |
The props are discriminated on exclusive: without it (or with exclusive={false}) the value is a string[]; with exclusive it is string | null, and deselectable becomes available. The mode is fixed when the group mounts.
| Prop | Type | Default | Description |
|---|---|---|---|
exclusive | boolean | false | At most one Item pressed; value becomes string | null. |
value | string[] | (exclusive) string | null | – | Controlled pressed values. [] / null presses nothing; undefined means uncontrolled. Order is not significant. |
defaultValue | string[] | (exclusive) string | null | [] / null | Initial pressed values in uncontrolled mode. |
valueChangeAction | ChangeAction<string[] | (exclusive) string | null, ToggleGroupValueChangeDetails> | – | Runs inside the Root's transition on every change. next is a fresh array in document order (exclusive: the value or null); details is { added, removed } plus the virtual event. Rejections roll the change back and reach the nearest Error Boundary. |
deselectable | boolean | true | Exclusive only. false: pressing the pressed Item does nothing, so the action never receives null. |
orientation | "horizontal" | "vertical" | "horizontal" | Axis of the arrow keys and data-orientation. Ignored inside a Toolbar (development warning). |
loop | boolean | true | Arrows wrap from the last Item to the first. Ignored inside a Toolbar. |
disabled | boolean | false | Disables every Item (native disabled). |
experimental_focusgroup | boolean | false | Renders focusgroup="toolbar … nomemory" and focusgroupstart on the first pressed Item; where the browser implements it (Chromium 150+) the browser moves focus. Ignored inside a Toolbar. See Experimental features. |
className | string | ((state: ToggleGroupRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToggleGroupRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToggleGroupRootState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: ToggleGroupRootState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
ToggleGroupRootState is { value, pending, disabled, orientation }; value is the optimistic set as an array in document order (exclusive mode: [value] or []). Every other <div> prop passes through, except role and the handlers of the roving focus (onKeyDown, onFocus, onBlur, onPointerDown), which are not props.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The value this Item presses. Unique within the group. |
disabled | boolean | false | Native disabled; never dispatches; skipped by the arrow keys. |
transition | ViewTransitionClass | – | Forwarded to the Item's <ViewTransition default>. Animates the commit that ends the group's action, not the optimistic flip. |
className | string | ((state: ToggleGroupItemState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToggleGroupItemState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToggleGroupItemState) => ReactNode) | – | Rendered from optimistic state; must not suspend. |
render | ReactElement | ((props, state: ToggleGroupItemState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
ToggleGroupItemState is { pressed, pending, disabled, orientation }. onClick, type, aria-pressed and tabIndex are the library's: the roving focus writes tabindex itself.
| Attribute | On | Meaning |
|---|---|---|
data-orientation | Root, Item | The effective orientation (the Toolbar's inside one). |
data-pending | Root | An action dispatched through the group is in flight. |
data-pending | Item | In flight and this Item's optimistic state differs from its committed one. |
data-pressed | Item | Optimistically pressed (agrees with aria-pressed). |
data-disabled | Root, Item | Disabled (the Item's own or the Root's). |
| Key | Horizontal | Vertical | Effect |
|---|---|---|---|
| ArrowRight / ArrowLeft | Next / previous enabled Item (swapped under direction: rtl) | Ignored | Moves focus only; wraps if loop. Never presses. |
| ArrowDown / ArrowUp | Ignored | Next / previous enabled Item | Same. |
| Home / End | First / last enabled Item | Same | Same. |
| Space / Enter | Toggles the focused Item (native button activation). | ||
| Tab / Shift+Tab | Enters on the first pressed enabled Item, else the first enabled one; leaves with the next press. |
role="group" with no aria-orientation (ARIA does not allow it on a group); orientation is data-orientation only.aria-pressed is always "true" or "false" and follows the optimistic state. Focus never changes it.tabindex..group[data-pending] { cursor: progress; }
.item[data-pressed] { background: var(--accent); color: white; }
.item[data-pending]::after { content: ""; /* spinner */ }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}The optimistic flip is a synchronous commit: animate it with a CSS transition on [data-pressed]. View transition types are fsui-toggle-group, plus fsui-toggle-group-press and / or fsui-toggle-group-release.