A group of checkboxes sharing one value, a set of strings: a <fieldset> of real <input type="checkbox"> elements with a shared name. The platform supplies the keyboard, labels, forms and validation. The library adds the set-valued state changed through valueChangeAction in a transition (optimistic, with data-pending), a mixed-state Parent box that checks or unchecks a subset (the WAI-ARIA "select all"), and an "at least one" required.
"use client";
import { startTransition, useId, useRef, useState } from "react";
import { CheckboxGroup } from "@funstack/ui/checkbox-group";
import type { CheckboxGroupValueChangeDetails } from "@funstack/ui/checkbox-group";
import { fakeSave } from "./fake-api.js";
const toppings = [
{ value: "cheese", label: "Cheese" },
{ value: "ham", label: "Ham" },
{ value: "olives", label: "Olives" },
{ value: "basil", label: "Basil" },
];
/** The functional update keyed by what changed: overlapping saves can never lose a change. */
function applyChange(prev: readonly string[], change: CheckboxGroupValueChangeDetails) {
return [
...prev.filter((v) => !change.removed.includes(v)),
...change.added.filter((v) => !prev.includes(v)),
];
}
// Controlled, "update on success": the parent adopts a change only after its save resolves. The
// Parent box derives its checked / mixed state from the Items' optimistic state and its click
// follows the APG cycle: mixed → all → none → the last combination that was saved → … Every
// click is one `valueChangeAction` with `added` / `removed`. A failed save is caught and not
// adopted, so the boxes roll back (the Parent to mixed) when it settles. `aria-controls` on the
// Parent lists the boxes it controls, as in the APG mixed-state example (it passes through).
export function CheckboxGroupToppings() {
const idPrefix = useId();
const itemId = (value: string) => `${idPrefix}-${value}`;
const [selected, setSelected] = useState<string[]>(["cheese"]);
// Read from a ref: state set inside a pending transition is not visible to a second click.
const failNext = useRef(false);
const [failNextShown, setFailNextShown] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<>
<CheckboxGroup.Root
className="demo-checkbox-group"
value={selected}
valueChangeAction={async (next, details) => {
setError(null);
const fail = failNext.current;
failNext.current = false;
if (fail) setFailNextShown(false);
try {
await fakeSave(`[${next.join(", ")}]`, { fail });
startTransition(() => setSelected((prev) => applyChange(prev, details)));
} catch (e) {
startTransition(() => setError(e instanceof Error ? e.message : String(e)));
}
}}
>
<CheckboxGroup.Label>
{({ pending }) => (
<>Toppings {pending && <span className="demo-checkbox-group-saving">saving…</span>}</>
)}
</CheckboxGroup.Label>
<label className="demo-field">
<CheckboxGroup.Parent
values={toppings.map((t) => t.value)}
aria-controls={toppings.map((t) => itemId(t.value)).join(" ")}
className="demo-checkbox"
/>
All toppings
<span className="demo-spinner demo-checkbox-group-spinner" aria-hidden />
</label>
{toppings.map((topping) => (
<label key={topping.value} className="demo-field demo-nested">
<CheckboxGroup.Item
id={itemId(topping.value)}
value={topping.value}
className="demo-checkbox"
/>
{topping.label}
<span className="demo-spinner demo-checkbox-group-spinner" aria-hidden />
</label>
))}
</CheckboxGroup.Root>
<div className="demo-row demo-options">
<label className="demo-field">
<input
type="checkbox"
checked={failNextShown}
onChange={(e) => {
failNext.current = e.target.checked;
setFailNextShown(e.target.checked);
}}
/>
Make the next save fail
</label>
</div>
<output className="demo-readout">
saved toppings: <code>{selected.join(", ") || "none"}</code>
{error && <span className="demo-inline-error"> {error}</span>}
</output>
</>
);
}/* CheckboxGroup: the Root is a <fieldset>, the Label its <legend>. Items and Parents are styled
with `.demo-checkbox` from the Checkbox page (checkbox.css): the input is the box, ::before
draws the check or the dash from [data-checked] / [data-indeterminate]. Both agree with the
DOM, which holds the optimistic state. */
.demo-checkbox-group {
display: grid;
gap: 0.4rem;
justify-items: start;
margin: 0;
padding: 0.5rem 1rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.demo-checkbox-group legend {
padding-inline: 0.3rem;
font-weight: 600;
color: var(--color-text);
}
.demo-checkbox-group[data-pending] {
cursor: progress;
}
/* An empty required group after a submit attempt (every Item is `required` while empty). */
.demo-checkbox-group:has(.demo-checkbox:user-invalid) legend {
color: #dc2626;
}
.demo-field:has(.demo-checkbox:disabled) {
cursor: not-allowed;
}
/* The row spinner shows only next to the boxes whose state is changing. */
.demo-checkbox-group-spinner {
display: none;
}
.demo-field:has(.demo-checkbox[data-pending]) .demo-checkbox-group-spinner {
display: inline-block;
}
.demo-checkbox-group-saving {
font-weight: normal;
font-size: 0.85em;
color: var(--color-text-secondary);
}import { CheckboxGroup } from "@funstack/ui/checkbox-group";
<CheckboxGroup.Root name="toppings" defaultValue={["cheese"]} valueChangeAction={saveToppings}> {/* fieldset */}
<CheckboxGroup.Label>Toppings</CheckboxGroup.Label> {/* legend: optional, first child */}
<label>
<CheckboxGroup.Parent values={["cheese", "ham"]} /> All toppings {/* mixed-state box */}
</label>
<label>
<CheckboxGroup.Item value="cheese" /> Cheese {/* input type=checkbox */}
</label>
<label>
<CheckboxGroup.Item value="ham" /> Ham
</label>
</CheckboxGroup.Root><fieldset> with its native role group: the legend names it, disabled reaches every box natively, and it finds the form for resets. A render element must stay a <fieldset>.<legend> and must be the Root's first child. Its function children receive the Root's state.value is in the set. A plain Checkbox inside the Root is not a member; membership is explicit through Item.values you give it: checked when all are, mixed when some are, and its click sets them all. It has no name, so it is never submitted. Several Parents may share a group, one per section; one listing every value acts as a grand-parent.appearance: none and ::before, see the Checkbox page) or use accent-color.value and defaultValue are string[], so a useState<string[]> setter plugs in. Order does not matter and duplicates are ignored. next is a fresh array in the document order of the Items (what formData.getAll(name) returns); compare it as a set. details.added and details.removed say what changed: one value for an Item click, a whole subset for a Parent click, the diff for a form reset. A value with no Item is kept through clicks and never submitted.
CheckboxGroup.Root| Prop | Type | Default | Description |
|---|---|---|---|
value | string[] | – | Controlled checked values; [] checks nothing. undefined means uncontrolled. Order is not significant; duplicates are ignored. |
defaultValue | string[] | [] | Initial values in uncontrolled mode. Also what a form reset returns to: the latest value is mirrored into the boxes' defaultChecked. |
valueChangeAction | ChangeAction<string[], CheckboxGroupValueChangeDetails> | – | Runs in the Root's transition whenever the set changes; sync, async or a Server Function. Rejections reach the nearest Error Boundary and roll the change back. details is { reason, added, removed } (plus the virtual event): reason is "activate" for an Item or Parent click and "platform" for a form reset or a change found at hydration. |
name | string | useId() | The name shared by every Item: the form data key (formData.getAll(name)). Pass a real name inside a form you submit. |
required | boolean | false | At least one enabled Item must be checked: every Item is rendered required while no enabled Item is checked, none once one is (see Forms). |
disabled | boolean | false | Renders <fieldset disabled>: every Item and Parent is disabled and unfocusable. |
form | string | – | The form attribute, applied to the fieldset and to every Item. |
className | string | ((state: CheckboxGroupRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: CheckboxGroupRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: CheckboxGroupRootState) => ReactNode) | – | The Label, Parents and Items. The function form receives the optimistic value; must not suspend. |
render | ReactElement | ((props, state: CheckboxGroupRootState) => ReactNode) | – | Replace the default <fieldset> (element form) or take over rendering (function form). |
CheckboxGroupRootState: { value, pending, disabled }, with value the optimistic set as an array in the Items' document order as of the Root's last render (Items reordered by a component that re-renders without the Root are picked up at its next render; next always reads the current order). The Root owns disabled and form and writes no role; other fieldset props pass through (aria-label for a group without a Label, for example).
CheckboxGroup.Label| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: CheckboxGroupRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: CheckboxGroupRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: CheckboxGroupRootState) => ReactNode) | – | Content, or a function of the Root's state. |
render | ReactElement | ((props, state: CheckboxGroupRootState) => ReactNode) | – | Replace the default <legend> (element form) or take over rendering (function form). |
CheckboxGroup.Item| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Required. The value this box contributes when checked; unique within the Root. Submitted as the form value. |
disabled | boolean | false | Native disabled: unfocusable, never dispatches, not validated. A Parent leaves it alone, and checked it does not satisfy required. |
transition | ViewTransitionClass | – | Forwarded to the Item's <ViewTransition default>; without it no boundary is rendered. Fires only on the commit that settles an action or rolls it back, never on the click itself. Keep it static. |
className | string | ((state: CheckboxGroupItemState) => string | undefined) | – | A class name, or a function of the Item's state. |
style | CSSProperties | ((state: CheckboxGroupItemState) => CSSProperties | undefined) | – | Inline style, or a function of the Item's state. |
render | ReactElement | ((props, state: CheckboxGroupItemState) => ReactNode) | – | Replace the default <input>. Must keep an <input>; the library sets type="checkbox". |
CheckboxGroupItemState: { value, checked, pending, disabled }. checked is optimistic; pending is true while an action is in flight and this box's optimistic state differs from the committed one (click a box twice quickly and it is not pending, the Root still is); disabled includes Root disabled. The Item owns type, name, value, checked, defaultChecked, disabled, form, required (computed by the Root) and aria-checked (removed); onChange is merged; id, role and other input props pass through. There is no per-Item required: use a plain Checkbox for "this box must be checked".
CheckboxGroup.Parent| Prop | Type | Default | Description |
|---|---|---|---|
values | string[] | – | Required. The values this Parent controls, usually every Item's value. Values whose Items are disabled are left alone (see Click cycle); values with no Item are still added and removed. |
disabled | boolean | false | Native disabled: unfocusable, never dispatches. |
transition | ViewTransitionClass | – | Forwarded to the Parent's <ViewTransition default>; without it no boundary is rendered. Fires only on the commit that settles an action or rolls it back, never on the click itself. Keep it static. |
className | string | ((state: CheckboxGroupParentState) => string | undefined) | – | A class name, or a function of the Parent's state. |
style | CSSProperties | ((state: CheckboxGroupParentState) => CSSProperties | undefined) | – | Inline style, or a function of the Parent's state. |
render | ReactElement | ((props, state: CheckboxGroupParentState) => ReactNode) | – | Replace the default <input>. Must keep an <input>; the library sets type="checkbox". |
CheckboxGroupParentState: { values, checked, indeterminate, pending, disabled }. checked: every value is in the optimistic set (false for empty values); indeterminate: some but not all, never together with checked, and, unlike a plain Checkbox, not hidden while pending (it is derived from the optimistic set, so the DOM property, the attribute and :indeterminate always agree); pending: an action is in flight and one of its values is changing. The Parent owns type, checked, defaultChecked, indeterminate, disabled and aria-checked, always removes name, value, form and required, and renders autocomplete="off" (it is not a value, so a browser restoring form state on reload must not restore it). Other props pass through, such as aria-controls with the ids of the boxes it controls (as in the example above).
The Parent follows the WAI-ARIA APG mixed-state checkbox example:
| Parent shows | Click | details |
|---|---|---|
| mixed | Checks every value. | added: the missing values |
| checked | Unchecks every value. | removed: all its values |
| unchecked, after a partial combination was saved | Restores that combination. | added: the remembered values |
| unchecked, no saved partial | Checks every value. | added: all its values |
The remembered combination is the last partial one the group committed (the mount-time value counts), never a state that was only pending and then rolled back. From "none", a user who wants "all" after a remembered combination clicks twice.
Disabled Items are left alone. "Its values" above are the values whose Items are enabled: a click never checks or unchecks a disabled Item (its own disabled prop), and the checked / mixed / unchecked state is derived without them, so a Parent shows checked when every enabled Item is, whatever the disabled ones hold. Values with no Item count as enabled. When every value has only disabled Items, the Parent acts on all of them; disable the Parent to make it inert. Root disabled disables everything and does not change the derivation. The server cannot know which Items are disabled, so the server-rendered Parent counts them until the Items hydrate.
| Attribute | On | Pseudo-class | Meaning |
|---|---|---|---|
data-pending | Root | – | valueChangeAction is in flight. |
data-pending | Item, Parent | – | An action is in flight and this box's (or one of the Parent's values') state is changing. |
data-checked | Item, Parent | :checked | Optimistic checked state; the DOM holds it. |
data-indeterminate | Parent | :indeterminate | Some but not all of its values are checked. The attribute also works before hydration. |
data-disabled | Root, Item, Parent | :disabled | Disabled by prop; on an Item or Parent, by its own prop or Root disabled. |
| – | Item | :required, :user-invalid, :focus-visible | Platform only; no attribute. |
.box:checked::before { scale: 1; } /* flips on click, reverts on rejection */
.box[data-indeterminate]::before { /* the dash */ }
.box[data-pending] { animation: pulse 1s infinite alternate; }
.group[data-pending] { cursor: progress; }
.group:has(.box:user-invalid) legend { color: red; } /* empty required group after a submit */Everything here is the browser's; the library installs no key handler.
| Key | Behaviour | Dispatches |
|---|---|---|
| Tab / Shift+Tab | Every enabled Item and Parent is a tab stop, in document order. | no |
| Space | Toggles the focused box (a Parent follows the click cycle). | yes |
| Enter | Submits the form (implicit submission). | no |
| Arrows, Home, End | Nothing (the APG defines no arrow keys for checkboxes). | – |
group named by its legend, as the APG advises for related checkboxes. The Parent's mixed state reaches assistive technology through the indeterminate property (exposed as aria-checked="mixed"), set after hydration.aria-required on role group, so a required group is not announced as such on the fieldset: say it in the Label ("choose at least one") or through aria-describedby on the Root. While no enabled box is checked, every box carries native required, which engines expose differently (Chromium's accessibility tree marks the boxes invalid, not required), so the Label or description is the reliable announcement.aria-live region if a slow action may fail. The library never moves focus.valueChangeAction(["cheese", "ham"], { reason: "activate", added: ["ham"], removed: [] }) in the Root's transition. The Root and the Ham box get data-pending; a Parent over both values shows checked at once.checked back, the Parent returns to mixed, and the error reaches the nearest Error Boundary."use client";
import { startTransition, useState } from "react";
import { useFormStatus } from "react-dom";
import { CheckboxGroup } from "@funstack/ui/checkbox-group";
import type { CheckboxGroupValueChangeDetails } from "@funstack/ui/checkbox-group";
import { fakeSave } from "./fake-api.js";
const permissions = [
{ value: "users:read", label: "Read users", section: "users" },
{ value: "users:write", label: "Edit users", section: "users" },
{ value: "billing:read", label: "Read billing", section: "billing" },
{ value: "billing:write", label: "Edit billing", section: "billing" },
];
const inSection = (section: string) =>
permissions.filter((p) => p.section === section).map((p) => p.value);
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" className="demo-button demo-primary">
{pending && <span className="demo-spinner" aria-hidden />}
Invite
</button>
);
}
// The boxes post `perm=…` once per checked box (a real `name`; read it with
// `formData.getAll("perm")`). `required` on the Root means "at least one": while nothing is
// checked every box is `required`, so the browser blocks the submit with its own message; once
// one is checked nothing is. Two section Parents control their own values. After the form action
// succeeds React resets the form and the group follows: one "platform" dispatch with the diff.
export function CheckboxGroupForm() {
const [invited, setInvited] = useState<string[] | null>(null);
const [log, setLog] = useState<string[]>([]);
const record = (_next: string[], { reason, added, removed }: CheckboxGroupValueChangeDetails) => {
const entry = `${reason}: +[${added.join(", ")}] −[${removed.join(", ")}]`;
setLog((prev) => [entry, ...prev].slice(0, 4));
};
return (
<form
className="demo-stack"
action={async (formData) => {
await fakeSave("the invitation");
const perms = formData.getAll("perm").map(String);
startTransition(() => setInvited(perms));
}}
>
<CheckboxGroup.Root
className="demo-checkbox-group"
name="perm"
required
valueChangeAction={record}
>
<CheckboxGroup.Label>Permissions (choose at least one)</CheckboxGroup.Label>
{["users", "billing"].map((section) => (
<div key={section} className="demo-stack">
<label className="demo-field">
<CheckboxGroup.Parent values={inSection(section)} className="demo-checkbox" />
All {section}
</label>
{permissions
.filter((p) => p.section === section)
.map((p) => (
<label key={p.value} className="demo-field demo-nested">
<CheckboxGroup.Item value={p.value} className="demo-checkbox" />
{p.label}
</label>
))}
</div>
))}
</CheckboxGroup.Root>
<div className="demo-row">
<SubmitButton />
<button type="reset" className="demo-button">
Reset
</button>
</div>
<output className="demo-readout">
invited with: <code>{invited === null ? "(not yet)" : JSON.stringify(invited)}</code>
<br />
valueChangeAction calls, latest first: <code>{log.join(" · ") || "(none yet)"}</code>
</output>
</form>
);
}/* CheckboxGroup: the Root is a <fieldset>, the Label its <legend>. Items and Parents are styled
with `.demo-checkbox` from the Checkbox page (checkbox.css): the input is the box, ::before
draws the check or the dash from [data-checked] / [data-indeterminate]. Both agree with the
DOM, which holds the optimistic state. */
.demo-checkbox-group {
display: grid;
gap: 0.4rem;
justify-items: start;
margin: 0;
padding: 0.5rem 1rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.demo-checkbox-group legend {
padding-inline: 0.3rem;
font-weight: 600;
color: var(--color-text);
}
.demo-checkbox-group[data-pending] {
cursor: progress;
}
/* An empty required group after a submit attempt (every Item is `required` while empty). */
.demo-checkbox-group:has(.demo-checkbox:user-invalid) legend {
color: #dc2626;
}
.demo-field:has(.demo-checkbox:disabled) {
cursor: not-allowed;
}
/* The row spinner shows only next to the boxes whose state is changing. */
.demo-checkbox-group-spinner {
display: none;
}
.demo-field:has(.demo-checkbox[data-pending]) .demo-checkbox-group-spinner {
display: inline-block;
}
.demo-checkbox-group-saving {
font-weight: normal;
font-size: 0.85em;
color: var(--color-text-secondary);
}name, every checked Item posts name=value; read them with formData.getAll(name). Parents post nothing.required means "at least one". HTML has no group constraint, so the Root renders native required on every Item while no enabled Item is checked (following the optimistic state) and on none once one is. A checked disabled Item and a value with no Item do not count: neither is submitted. Validation, the browser's localised message and :user-invalid stay native; the message reads "check this box". Disabled boxes are not validated, so a disabled group never blocks a submit.required on the server, so a submit before hydration is not blocked (under-blocking, until hydration); validate on the server as well.form.reset(), or React's automatic reset after the action succeeds) is one dispatch for the whole group, with reason: "platform", the reset event, and the diff in added / removed. Uncontrolled, the target is the latest defaultValue; controlled, the mount-time values are offered to the parent.autocomplete="off"), so a restore cannot be taken for a Parent click.// Autosave: a per-group action with data-pending, passed directly from a Server Component.
// details ({ reason, added, removed }) is serialisable.
<CheckboxGroup.Root defaultValue={order.toppings} valueChangeAction={saveToppings}>...</CheckboxGroup.Root>
// Native submission: no action on the group; pending is the form's.
<form action={createOrder}>
<CheckboxGroup.Root name="toppings" required>...</CheckboxGroup.Root>
<SubmitButton />
</form>The two channels are independent and may be combined. The group never reads useFormStatus. An action-less group is a working set of native checkboxes before hydration and with JavaScript off; the Parent's cascade and the dynamic required need hydration.
Transition types: every dispatch adds fsui-checkbox-group, plus fsui-checkbox-group-check when it checks something and fsui-checkbox-group-uncheck when it unchecks something (a reset can carry both).