Checkbox
When to use
Section titled “When to use”- Accepting terms before submitting a form
- Choosing several independent options, such as which emails to receive
- Selecting rows in a list, with the indeterminate state for “some”
Not for a setting that should take effect at once — use a Switch.
Choosing between Checkbox, Switch or Toggle:
| Component | Use it for | For example |
|---|---|---|
| Checkbox | A yes/no choice, or one of several independent options, that applies when the form is submitted | “I agree to the terms”, “Email me updates” |
| Switch | A setting that takes effect the moment it is flipped, with no Save button | “Dark mode”, “Wi-Fi”, “Notifications on” |
| Toggle | A button that stays pressed, usually in a toolbar, for a mode or formatting | “Bold”, “Mute”, “Pin” |
import { Checkbox } from '@arun-dev/ui';
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
export default function CheckboxBasics() { return ( <> {/* A checkbox has no accessible name of its own — the label supplies it, associated by id so jsx-a11y accepts the <button> the checkbox renders. */} <label htmlFor="terms" style={row}> <Checkbox.Root id="terms"> <Checkbox.Indicator /> </Checkbox.Root> Accept terms </label>
<label htmlFor="newsletter" style={row}> <Checkbox.Root id="newsletter" defaultChecked> <Checkbox.Indicator /> </Checkbox.Root> Newsletter </label>
<label htmlFor="partial" style={row}> <Checkbox.Root id="partial" defaultChecked="indeterminate"> <Checkbox.Indicator /> </Checkbox.Root> Indeterminate </label>
<label htmlFor="checkbox-disabled" style={row}> <Checkbox.Root id="checkbox-disabled" disabled> <Checkbox.Indicator /> </Checkbox.Root> Disabled </label> </> );}It needs a name
Checkbox.Root has no accessible name of its own — a headless component should not guess at your
copy. Associate a <label> with it by id:
<label htmlFor="terms"> <Checkbox.Root id="terms"> <Checkbox.Indicator /> </Checkbox.Root> Accept terms</label>Checkbox.Root renders a <button>, so wrapping it in a <label> would name it implicitly and
the htmlFor looks redundant. Keep it. jsx-a11y/label-has-associated-control only accepts
input, meter, output, progress, select and textarea as a nested control, so it
rejects a <button> that HTML labels perfectly well. The explicit association satisfies the
rule, and you avoid disabling it at every call site.
Where there is no visible copy, pass aria-label or aria-labelledby instead.
Three states, one prop
Section titled “Three states, one prop”checked is boolean | 'indeterminate' rather than a checked plus an indeterminate flag.
One prop means checked && indeterminate cannot be written, and everything else — the ARIA, the
data-* attributes, the submitted value — derives from the single value.
The state exists for a parent standing in for a set of children, where some is a real answer:
import { useState } from 'react';import { Checkbox } from '@arun-dev/ui';
const SCOPES = ['Read', 'Write', 'Delete'] as const;
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
/** * The case indeterminate exists for: a parent standing in for a set of children, where * "some" is a real answer and neither checked nor unchecked can say it. */export default function CheckboxIndeterminate() { const [granted, setGranted] = useState<string[]>(['Read']);
// Derived, never stored — the parent has no state of its own to fall out of sync. const parent = granted.length === SCOPES.length ? true : granted.length === 0 ? false : 'indeterminate';
return ( <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2xs)' }}> <label htmlFor="all-scopes" style={row}> <Checkbox.Root id="all-scopes" checked={parent} // Mixed resolves to checked on click, so this grants the rest rather than // clearing what is already granted. onCheckedChange={(next) => setGranted(next === true ? [...SCOPES] : [])} > <Checkbox.Indicator /> </Checkbox.Root> All permissions </label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2xs)', paddingInlineStart: 'var(--space-md)', }} > {SCOPES.map((scope) => ( <label key={scope} htmlFor={`scope-${scope}`} style={row}> <Checkbox.Root id={`scope-${scope}`} checked={granted.includes(scope)} onCheckedChange={(next) => setGranted((current) => next === true ? [...current, scope] : current.filter((s) => s !== scope), ) } > <Checkbox.Indicator /> </Checkbox.Root> {scope} </label> ))} </div> </div> );}Keep the parent’s value derived from the children, as above, rather than stored. A stored copy is a second source of truth, and the two drift the moment a child changes by some other route.
A click never moves to indeterminate — only a parent can set it. Activating a mixed checkbox
resolves it to checked, as a native one does, so the cycle a user sees is
indeterminate → checked → unchecked → checked.
Why not a native input?
An <input type="checkbox"> is a void element, so it cannot contain an indicator — the content
test in decision 3. That is the same reason Switch.Root is a
<button>, and everything the input was still good for, the hidden one below keeps doing.
Native indeterminate would not have saved any work either. It is a DOM property with no
attribute behind it, so React cannot set it declaratively; the first click clears it behind
React’s back; and form.reset() leaves it untouched. All three would need the same JavaScript
this component already has, plus a ref to apply it to.
Playground
Section titled “Playground”<Checkbox.Root><Checkbox.Indicator /></Checkbox.Root>Controlled
Section titled “Controlled”Pass checked with onCheckedChange and the parent owns the value — the checkbox will not move
on its own. onCheckedChange reports the value being moved to in both modes, so the same handler
works either way.
Never pass an undefined checked
The mode is decided once, at mount, and never re-evaluated. A checked of undefined on the
first render makes the checkbox uncontrolled for the rest of its life — it starts tracking its
own state and ignores every value you pass afterwards. This bites when the value loads
asynchronously. Coalesce at the call site:
<Checkbox.Root checked={x ?? false} onCheckedChange={setX} />defaultChecked does not guard against this. It is ignored in controlled mode, and only makes
the first render look correct while the checkbox has already taken ownership.
A mark of your own
Section titled “A mark of your own”Checkbox.Indicator ships a check and a dash. Children replace both at once, so supply a mark
for every state you want to show.
Which one is visible is a CSS question, not a React one — the Indicator carries the same
data-checked / data-indeterminate as the Root, so the stylesheet decides. That is what keeps
this working in an uncontrolled checkbox, where the state is not in your hands at all.
import { Checkbox } from '@arun-dev/ui';
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
/** * Children replace both default marks at once, so a custom checkbox supplies its own * for every state it wants to show. * * Which one is visible is a CSS question, not a React one: the Indicator carries the * same `data-checked` / `data-indeterminate` as the Root, so the stylesheet decides. * That is why nothing here reads the checked state — and why this works the same in an * uncontrolled checkbox, where the state is not in the consumer's hands at all. */const MARK_CSS = ` .demo-mark [data-when] { display: none; } .checkbox[data-checked] .demo-mark [data-when='checked'], .checkbox[data-indeterminate] .demo-mark [data-when='indeterminate'] { display: block; }`;
function StarMark() { return ( <Checkbox.Indicator className="demo-mark"> <span data-when="checked">★</span> <span data-when="indeterminate">☆</span> </Checkbox.Indicator> );}
export default function CheckboxCustomMark() { return ( <> <style>{MARK_CSS}</style>
<label htmlFor="starred" style={row}> <Checkbox.Root id="starred" defaultChecked> <StarMark /> </Checkbox.Root> Starred </label>
<label htmlFor="starred-partial" style={row}> <Checkbox.Root id="starred-partial" defaultChecked="indeterminate"> <StarMark /> </Checkbox.Root> Partially starred </label> </> );}The marks are decorative: the Root already carries role="checkbox" and aria-checked, and the
Indicator is aria-hidden, so a screen reader hears the state once rather than twice.
render replaces the Indicator’s element rather than its contents, which is what you want when
the mark component renders its own root — the wrapper span disappears, and the component receives
className, aria-hidden and the data-* state directly.
With react-hook-form
Section titled “With react-hook-form”Bind it with Controller, not register() — register fails quietly here, because it returns
props for a native input and the state lives in React. See the
react-hook-form guide.
<Controller control={control} name="terms" render={({ field }) => ( <Checkbox.Root checked={field.value} onCheckedChange={field.onChange} onBlur={field.onBlur} aria-label="Accept terms" > <Checkbox.Indicator /> </Checkbox.Root> )}/>Anatomy
Section titled “Anatomy”import { Checkbox } from '@arun-dev/ui';
<Checkbox.Root> <Checkbox.Indicator /></Checkbox.Root>;Checkbox.Indicator reads its state from Checkbox.Root, so the two cannot get out of step. It
renders in every state, checked or not, so its mark has something to animate from.
Checkbox.Root
Section titled “Checkbox.Root”| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | 'indeterminate' | Controlled state. Provide onCheckedChange alongside it. Never pass undefined — the mode is fixed at mount, so write checked={x ?? false}. | |
defaultChecked | boolean | 'indeterminate' | false | Initial state when uncontrolled. Read once, at mount. |
onCheckedChange | (checked: boolean | 'indeterminate') => void | Called with the value being moved to, in both controlled and uncontrolled modes. A click never moves to indeterminate — only a parent can set it. | |
disabled | boolean | false | Prevents activation. |
name | string | Submits with the enclosing form when checked. An unchecked or indeterminate checkbox contributes nothing, mirroring a native checkbox. | |
value | string | 'on' | Value submitted when checked. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Typing a wrapper
Section titled “Typing a wrapper”@arun-dev/ui does not export a props type for the checkbox. The type is defined by
@arun-dev/headless, and a re-export would only record where it lives today. Derive it from the
component instead:
import type { ComponentProps } from 'react';import { Checkbox } from '@arun-dev/ui';
function TermsCheckbox(props: ComponentProps<typeof Checkbox.Root>) { return ( <Checkbox.Root {...props}> <Checkbox.Indicator /> </Checkbox.Root> );}Using the headless parts directly, the types are exported by the package that owns them:
import type { CheckboxRootProps, CheckboxIndicatorProps, CheckedState } from '@arun-dev/headless/checkbox'.
Behaviour
Section titled “Behaviour”-
A native
<button role="checkbox">, so focus,SpaceandEntercome from the platform rather than from JavaScript.aria-checkedistrue,falseormixed— the ARIA spelling of the third state. -
disabledis the button’s own attribute, so the platform removes it from the tab order and suppresses activation.data-disabledis emitted alongside it for styling. -
type="button", so it never submits an enclosing form by accident. -
With a
name, it renders a hidden<input type="checkbox">carrying the value, soform.reset()andform.elementstreat it as the checkbox it behaves like. An unchecked or indeterminate checkbox submits nothing — checkedness alone decides, as in HTML. -
form.reset()restores the state it mounted with,indeterminateincluded. A native checkbox does not manage that much: the platform’s reset algorithm never touches.indeterminate. -
Both parts emit
data-checked/data-unchecked/data-indeterminate/data-disabled. The three checked states are three mutually exclusive attributes rather than one attribute with a value, so all three are addressable at equal specificity:.checkbox[data-checked] {background: var(--checkbox-bg-checked);}.checkbox[data-indeterminate] .checkbox-mark-dash {opacity: 1;} -
Checkbox.Indicatorisaria-hidden— the Root already announces the state. -
The mark transition is disabled under
prefers-reduced-motion.
Styling it yourself
Section titled “Styling it yourself”The behaviour, keyboard handling and accessibility all come from
@arun-dev/headless, which ships no CSS, no class names and no marks —
<Checkbox.Indicator /> there renders an empty <span>, and the check is yours to supply.
@arun-dev/ui is the layer with an opinion about what a checkmark looks like.
import { Checkbox } from '@arun-dev/headless/checkbox';Identical props and parts, styled entirely by you.