FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Checkbox

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.

saved selection: 1002
Show code
CheckboxSelectAll.tsx
"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.css
/* 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;
  }
}

Anatomy

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>.

Props

PropTypeDefaultDescription
checkedboolean–Controlled checked state. undefined means uncontrolled.
defaultCheckedbooleanfalseInitial checked state in uncontrolled mode. Also what a form reset returns to: the latest value is mirrored into the input's defaultChecked.
indeterminatebooleanfalseControlled-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.
checkedChangeActionChangeAction<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).
disabledbooleanfalseNative disabled; never dispatches.
valuestring"on"Submitted with the form when checked (platform default).
transitionViewTransitionClass–Forwarded to the Checkbox's <ViewTransition default>; without it no boundary is rendered. Animates the commit that ends an action, not the check itself.
classNamestring | ((state: CheckboxState) => string | undefined)–A class name, or a function of the state.
styleCSSProperties | ((state: CheckboxState) => CSSProperties | undefined)–Inline style, or a function of the state.
renderReactElement | ((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.

Data attributes and pseudo-classes

AttributePseudo-classMeaning
data-checked:checkedOptimistic checked state. Identical, with one engine exception (below).
data-indeterminate:indeterminateMixed 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:disabledThe disabled prop. Only :disabled also sees <fieldset disabled>.
–:required, :user-invalid, :focus-visiblePlatform only; no attribute.

Chromium and :checked while indeterminate

Chromium does not match :checked on a box that is both checked and indeterminate (Firefox does). If a box can be checked and indeterminate at once, select on [data-checked], as the example stylesheet does. The recommended "select all" representation is unchecked + mixed, which avoids the case.

Indeterminate

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.

  1. Click the mixed "Select all": the browser checks the box and clears its 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.
  2. Resolve: the parent adopts the selection, so checked is true and indeterminate false.
  3. Reject (tick "Make the next save fail"): the optimistic value reverts and, since the parent still says mixed, the dash comes back on settle.

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.

Keyboard and ARIA

InteractionBehaviour
SpaceToggles; a mixed box becomes checked (native).
Click, label clickToggles (native).
EnterDoes not toggle; may submit the form.
TabFocusable unless disabled.
  • Native role and state; the library adds no role and never sets aria-checked. The mixed state reaches assistive technology through the DOM property, after hydration.
  • The checked state AT reads is the optimistic one, so the change is heard at once. Keep the label stable.
  • required applies to one checkbox; there is no native "at least one of these" constraint.

Async behaviour

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}`}
/>

Forms

last submitted: (nothing yet)
Show code
CheckboxForm.tsx
"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.css
/* 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.

  • Form reset and clicks before hydration are the two platform paths. Both call 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.