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

Usage

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

  1. Tri-state, Radix-compatible API. The value is boolean | "indeterminate". aria-checked mirrors it as "true" | "false" | "mixed" so screen readers announce the partial state correctly, and data-state carries the same value as a CSS hook for styling each state independently.
  2. 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 when name is provided, so the value still participates in native <form> submission.
  3. Controlled + uncontrolled. Pass checked for fully controlled use, or defaultChecked (or nothing) for uncontrolled. The first toggle of an indeterminate checkbox always flips to true so the user reaches a determinate state with one click.
  4. Label slot wires for free. Pass label and 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.
  5. 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

PropTypeDefaultDescription
checkedboolean | "indeterminate"Controlled checked state. Pair with onCheckedChange.
defaultCheckedboolean | "indeterminate"falseUncontrolled initial state. Ignored when checked is provided.
onCheckedChange(checked: CheckboxCheckedState) => voidFired with the next checked state on every user toggle.
labelReactNodeOptional label rendered to the right of the box. Wired via htmlFor so clicking it toggles the checkbox.
disabledbooleanfalseDisables pointer + keyboard input. The label fades.
requiredbooleanfalseSets aria-required and propagates to the hidden mirror input when name is set.
namestringAdds a hidden mirror <input type="checkbox"> so the value participates in native form submission.
valuestring"on"Submitted value when name is set and the box is checked.
classNamestringMerged onto the underlying <button>.

The CheckboxCheckedState shape is:

type CheckboxCheckedState = boolean | "indeterminate";

Accessibility

  • The box renders as <button role="checkbox"> with aria-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 label is 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).
  • disabled propagates to the button (no keyboard / pointer reachability) and the label (cursor-not-allowed, reduced opacity).
  • required is reflected as aria-required on the button and propagated to the hidden mirror input when name is set, so native form validation reports a missing checkbox.

Credits

  • Extracted from: algoflashcards (src/platform/ui/checkbox.tsx). The source wrapped radix-ui's Checkbox.Root and Checkbox.Indicator with a peer-style Tailwind selector and a Phosphor CheckIcon. 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.