A checkbox (WAI-ARIA Checkbox pattern, two-state and mixed). It renders a real <input type="checkbox"> and behaves like Switch: optimistic checked in the DOM, data-pending while checkedChangeAction runs, native forms. It adds a controlled indeterminate state for "select all" parents.
"use client";
import { startTransition, useOptimistic, useRef, useState } from "react";
import { Checkbox } from "@funstack/ui/checkbox";
import { fakeSave } from "./fake-api.js";
const rows = [
{ id: "1001", title: "Invoice #1001" },
{ id: "1002", title: "Invoice #1002" },
{ id: "1003", title: "Invoice #1003" },
];
/** One selection change: a row toggled, or "select all" set. */
type Change = { readonly id: string; readonly next: boolean } | { readonly all: boolean };
function apply(selection: ReadonlySet<string>, change: Change): ReadonlySet<string> {
if ("all" in change) return new Set(change.all ? rows.map((r) => r.id) : []);
const s = new Set(selection);
if (change.next) s.add(change.id);
else s.delete(change.id);
return s;
}
// A tri-state "select all" built from plain Checkboxes. `indeterminate` is controlled-only: the
// parent derives it (and `checked`) from the rows. Both come from the rows' optimistic selection,
// so the parent box agrees with what the rows show while a save is in flight. Clicking the mixed
// box selects every row. While its own save is pending the dash is hidden (the library renders
// `indeterminate && !pending`); when a save fails the selection rolls back and the dash returns.
export function CheckboxSelectAll() {
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set(["1002"]));
const [optimistic, addChange] = useOptimistic(selected, apply);
// The "fail" switch is read from a ref: state set inside a pending transition would not be
// visible to a second click until the first save settles.
const failNext = useRef(false);
const [failNextShown, setFailNextShown] = useState(false);
const [error, setError] = useState<string | null>(null);
const all = optimistic.size === rows.length;
const some = optimistic.size > 0 && !all;
// Runs inside the Checkbox's transition. "Update on success": adopt the change only after the
// save resolves, with an updater so overlapping saves never close over a stale set.
async function save(change: Change) {
addChange(change);
setError(null);
const fail = failNext.current;
failNext.current = false;
if (fail) setFailNextShown(false);
try {
await fakeSave("the selection", { fail });
startTransition(() => setSelected((prev) => apply(prev, change)));
} catch (e) {
startTransition(() => setError(e instanceof Error ? e.message : String(e)));
}
}
return (
<>
<div className="demo-stack">
<label className="demo-field">
<Checkbox
className="demo-checkbox"
checked={all}
indeterminate={some}
checkedChangeAction={(next) => save({ all: next })}
/>
Select all
</label>
{rows.map((row) => (
<label key={row.id} className="demo-field demo-nested">
<Checkbox
className="demo-checkbox"
checked={optimistic.has(row.id)}
checkedChangeAction={(next) => save({ id: row.id, next })}
/>
{row.title}
</label>
))}
</div>
<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 selection: <code>{[...selected].join(", ") || "none"}</code>
{error && <span className="demo-inline-error"> {error}</span>}
</output>
</>
);
}/* Checkbox: the input is the box; ::before draws the check (clip-path) or the dash.
Selectors use [data-checked] rather than :checked because Chromium does not match :checked on
a box that is both checked and indeterminate. [data-indeterminate] also works before
hydration, where the DOM property (and :indeterminate) cannot be set yet. */
.demo-checkbox {
appearance: none;
flex: none;
margin: 0;
inline-size: 1.25em;
block-size: 1.25em;
border: 2px solid var(--color-text-secondary);
border-radius: 0.25em;
display: inline-grid;
place-content: center;
color: var(--color-on-primary);
cursor: inherit;
transition:
background-color 120ms,
border-color 120ms;
}
.demo-checkbox::before {
content: "";
inline-size: 0.7em;
block-size: 0.7em;
background: currentColor;
scale: 0;
transition: scale 100ms;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
.demo-checkbox:is([data-checked], [data-indeterminate]) {
background: var(--color-primary);
border-color: var(--color-primary);
}
.demo-checkbox[data-checked]::before {
scale: 1;
}
.demo-checkbox[data-indeterminate]::before {
scale: 1;
clip-path: none;
block-size: 0.18em;
}
.demo-checkbox[data-pending] {
opacity: 0.6;
}
.demo-checkbox:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.demo-checkbox:disabled {
opacity: 0.5;
}
.demo-checkbox:user-invalid {
border-color: #dc2626;
}
.demo-nested {
margin-inline-start: 1.6em;
}
.demo-options {
margin-top: 16px;
font-size: 0.9rem;
}
@media (prefers-reduced-motion: reduce) {
.demo-checkbox,
.demo-checkbox::before {
transition: none;
}
}import { Checkbox } from "@funstack/ui/checkbox";
<label>
<Checkbox defaultChecked={task.done} checkedChangeAction={setDone} />
Done
</label>One plain component rendering one <input>. No Indicator part: draw the check and the dash on the input with appearance: none and ::before (any SVG works through mask-image), use accent-color to recolour the native box, or hide the input with a clip and style a sibling through label:has(:checked). The label is your own <label>.
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | – | Controlled checked state. undefined means uncontrolled. |
defaultChecked | boolean | false | Initial checked state in uncontrolled mode. Also what a form reset returns to: the latest value is mirrored into the input's defaultChecked. |
indeterminate | boolean | false | Controlled-only mixed state, derived by the parent from other state. No defaultIndeterminate, no action. Rendered as the DOM property, :indeterminate and data-indeterminate, except while an action is pending. Does not change what the form submits. |
checkedChangeAction | ChangeAction<boolean, CheckboxCheckedChangeDetails> | – | Called with the next checked state on every change, inside the Checkbox's transition. Sync, async or a Server Function; rejections reach the nearest Error Boundary. details.reason: "activate" (the native click) or "platform" (a form reset with the reset event, or a change found at hydration with no event). |
disabled | boolean | false | Native disabled; never dispatches. |
value | string | "on" | Submitted with the form when checked (platform default). |
transition | ViewTransitionClass | – | Forwarded to the Checkbox's <ViewTransition default>; without it no boundary is rendered. Animates the commit that ends an action, not the check itself. |
className | string | ((state: CheckboxState) => string | undefined) | – | A class name, or a function of the state. |
style | CSSProperties | ((state: CheckboxState) => CSSProperties | undefined) | – | Inline style, or a function of the state. |
render | ReactElement | ((props, state: CheckboxState) => ReactNode) | – | Replace the default <input> or take over rendering. Must keep an <input type="checkbox">; a custom component must forward ref to it (the indeterminate property is set through the ref). |
CheckboxState is { checked, indeterminate, pending, disabled }, with checked optimistic and indeterminate already indeterminate && !pending. Other <input> props pass through, including role. The Checkbox owns type, checked, defaultChecked, disabled and onChange, and removes aria-checked.
| Attribute | Pseudo-class | Meaning |
|---|---|---|
data-checked | :checked | Optimistic checked state. Identical, with one engine exception (below). |
data-indeterminate | :indeterminate | Mixed state, suspended while pending. Only the attribute works before hydration; :indeterminate also matches radios in an empty group. |
data-pending | – | checkedChangeAction is in flight. |
data-disabled | :disabled | The disabled prop. Only :disabled also sees <fieldset disabled>. |
| – | :required, :user-invalid, :focus-visible | Platform only; no attribute. |
Mixed is derived state: a "select all" parent computes it from its rows, which is why it is controlled-only and has no action. Clicking a mixed box dispatches !checked, so the usual unchecked + mixed box becomes checked (the APG's mixed to checked), exactly what the browser does to the DOM.
indeterminate property. The Checkbox dispatches true and, while pending, renders indeterminate && !pending: the attribute, the property and :indeterminate all agree that the dash is gone.checked is true and indeterminate false.In the example the parent box derives from the rows' optimistic selection (useOptimistic), so it never claims "all" while a row's uncheck is still saving. A CheckboxGroup that packages this pattern is being designed in issue #33.
Before hydration the DOM property cannot be set, and aria-checked="mixed" is forbidden on native inputs, so a mixed box is announced as unchecked until hydration. data-indeterminate is in the server HTML, so CSS can draw the dash from the start.
| Interaction | Behaviour |
|---|---|
| Space | Toggles; a mixed box becomes checked (native). |
| Click, label click | Toggles (native). |
| Enter | Does not toggle; may submit the form. |
| Tab | Focusable unless disabled. |
role and never sets aria-checked. The mixed state reaches assistive technology through the DOM property, after hydration.required applies to one checkbox; there is no native "at least one of these" constraint.Exactly as Switch: the DOM shows the optimistic value (a correctness requirement for native inputs), a rejection writes the old value back and reaches the Error Boundary, the latest of overlapping clicks wins, the box is never disabled while pending, and details.event.preventDefault() is not a veto (use controlled mode).
// Autosave with a Server Function, from a Server Component
<Checkbox
defaultChecked={task.done}
checkedChangeAction={setDone.bind(null, task.id)}
aria-label={`Done: ${task.title}`}
/>"use client";
import { startTransition, useState } from "react";
import { useFormStatus } from "react-dom";
import { Checkbox } from "@funstack/ui/checkbox";
import { fakeSave } from "./fake-api.js";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" className="demo-button demo-primary">
{pending && <span className="demo-spinner" aria-hidden />}
Sign up
</button>
);
}
// No change actions: native form participation. `required` blocks the submit until the terms
// box is checked (and :user-invalid styles it after the attempt). After the form action succeeds
// React resets the form to the `defaultChecked` values; the Reset button does the same.
export function CheckboxForm() {
const [submitted, setSubmitted] = useState("(nothing yet)");
return (
<form
className="demo-stack"
action={async (formData) => {
await fakeSave("the form");
startTransition(() => setSubmitted(JSON.stringify([...formData])));
}}
>
<label className="demo-field">
<Checkbox className="demo-checkbox" name="updates" value="yes" defaultChecked />
Send me product updates
</label>
<label className="demo-field">
<Checkbox className="demo-checkbox" name="terms" required />I accept the terms (required)
</label>
<div className="demo-row">
<SubmitButton />
<button type="reset" className="demo-button">
Reset
</button>
</div>
<output className="demo-readout">
last submitted: <code>{submitted}</code>
</output>
</form>
);
}/* Checkbox: the input is the box; ::before draws the check (clip-path) or the dash.
Selectors use [data-checked] rather than :checked because Chromium does not match :checked on
a box that is both checked and indeterminate. [data-indeterminate] also works before
hydration, where the DOM property (and :indeterminate) cannot be set yet. */
.demo-checkbox {
appearance: none;
flex: none;
margin: 0;
inline-size: 1.25em;
block-size: 1.25em;
border: 2px solid var(--color-text-secondary);
border-radius: 0.25em;
display: inline-grid;
place-content: center;
color: var(--color-on-primary);
cursor: inherit;
transition:
background-color 120ms,
border-color 120ms;
}
.demo-checkbox::before {
content: "";
inline-size: 0.7em;
block-size: 0.7em;
background: currentColor;
scale: 0;
transition: scale 100ms;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
.demo-checkbox:is([data-checked], [data-indeterminate]) {
background: var(--color-primary);
border-color: var(--color-primary);
}
.demo-checkbox[data-checked]::before {
scale: 1;
}
.demo-checkbox[data-indeterminate]::before {
scale: 1;
clip-path: none;
block-size: 0.18em;
}
.demo-checkbox[data-pending] {
opacity: 0.6;
}
.demo-checkbox:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.demo-checkbox:disabled {
opacity: 0.5;
}
.demo-checkbox:user-invalid {
border-color: #dc2626;
}
.demo-nested {
margin-inline-start: 1.6em;
}
.demo-options {
margin-top: 16px;
font-size: 0.9rem;
}
@media (prefers-reduced-motion: reduce) {
.demo-checkbox,
.demo-checkbox::before {
transition: none;
}
}Without change actions the boxes are native form controls: name, value and required pass through, :user-invalid marks the unchecked required box after a submit attempt, and a reset (the Reset button, or React's automatic reset after the action succeeds) restores defaultChecked.
checkedChangeAction with reason: "platform"; a controlled parent is offered the reset value and decides. Details on the Switch page.checkedChangeAction={serverFn} and <form action={serverFn}> are independent channels. The Checkbox never reads useFormStatus. A mixed box still posts its value when checked.