Checkbox
A Radix-style checkbox primitive. The value is a tri-state boolean | "indeterminate" flowing in via checked and out via onCheckedChange, with a parallel defaultChecked for the uncontrolled path. The box renders as a real <button role="checkbox"> so screen readers see aria-checked="true" | "false" | "mixed", and the focus ring, check glyph, and indeterminate bar are fully styleable through the --cb-* token system.
Preview
Installation
npx shadcn@latest add https://craftbits.dev/r/checkbox.jsonUsage
Controlled — pair checked with onCheckedChange so a parent owns the value:
import { Checkbox, type CheckboxCheckedState } from "@craft-bits/core";
const [agreed, setAgreed] = useState<CheckboxCheckedState>(false);
<Checkbox
checked={agreed}
onCheckedChange={setAgreed}
label="I accept the terms"
/>Uncontrolled — let the checkbox own the value, seed it via defaultChecked:
<Checkbox defaultChecked={false} label="Subscribe to updates" />Tri-state — drive checked with "indeterminate" for a parent select-all that reflects "some children on":
const parent: CheckboxCheckedState =
allOn ? true : noneOn ? false : "indeterminate";
<Checkbox checked={parent} onCheckedChange={toggleAll} label="Select all" />Understanding the component
- Tri-state, Radix-compatible API. The value is
boolean | "indeterminate".aria-checkedmirrors it as"true" | "false" | "mixed"so screen readers announce the partial state correctly, anddata-statecarries the same value as a CSS hook for styling each state independently. - Button under the hood. The box is a real
<button role="checkbox">, not a styled native<input>. That trades cross-browser styling quirks for a focus ring, check glyph, and indeterminate bar that paint identically on every engine. A hidden<input type="checkbox">is appended only whennameis provided, so the value still participates in native<form>submission. - Controlled + uncontrolled. Pass
checkedfor fully controlled use, ordefaultChecked(or nothing) for uncontrolled. The first toggle of an indeterminate checkbox always flips totrueso the user reaches a determinate state with one click. - Label slot wires for free. Pass
labeland a sibling<label htmlFor>is rendered automatically, so clicking the text toggles the box. Omit it and the component renders just the box — the caller can wrap it in their own label or expose it as a list cell. - Token-only theming. Border, fill, glyph colour, and focus halo all read from
--cb-*tokens. Theme swaps and dark mode repaint without a prop change.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | "indeterminate" | — | Controlled checked state. Pair with onCheckedChange. |
defaultChecked | boolean | "indeterminate" | false | Uncontrolled initial state. Ignored when checked is provided. |
onCheckedChange | (checked: CheckboxCheckedState) => void | — | Fired with the next checked state on every user toggle. |
label | ReactNode | — | Optional label rendered to the right of the box. Wired via htmlFor so clicking it toggles the checkbox. |
disabled | boolean | false | Disables pointer + keyboard input. The label fades. |
required | boolean | false | Sets aria-required and propagates to the hidden mirror input when name is set. |
name | string | — | Adds a hidden mirror <input type="checkbox"> so the value participates in native form submission. |
value | string | "on" | Submitted value when name is set and the box is checked. |
className | string | — | Merged onto the underlying <button>. |
The CheckboxCheckedState shape is:
type CheckboxCheckedState = boolean | "indeterminate";Accessibility
- The box renders as
<button role="checkbox">witharia-checked="true" | "false" | "mixed", so screen readers announce the determinate and indeterminate states correctly. data-state="checked" | "unchecked" | "indeterminate"mirrors the value as a CSS hook so each state can be styled independently without prop-derived classes.- When
labelis provided, a sibling<label htmlFor>is rendered with the same id as the button — clicking the label text toggles the box. - Keyboard: Space and Enter toggle the box (native
<button>semantics), Tab moves focus in DOM order. Focus is visible via a token-driven ring (--cb-accent-muted). disabledpropagates to the button (no keyboard / pointer reachability) and the label (cursor-not-allowed, reduced opacity).requiredis reflected asaria-requiredon the button and propagated to the hidden mirror input whennameis set, so native form validation reports a missing checkbox.
Credits
- Extracted from:
algoflashcards(src/platform/ui/checkbox.tsx). The source wrappedradix-ui'sCheckbox.RootandCheckbox.Indicatorwith a peer-style Tailwind selector and a PhosphorCheckIcon. craft-bits drops both external dependencies — the box is a plain<button role="checkbox">and the glyph is an inlined SVG — and broadens the API to a tri-state value plus an optional label slot, so the same primitive covers the bare checkbox, the labelled checkbox, and the parent select-all of a select-all/child-checkbox pair.