Skip to content

Checkbox

  • 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:

ComponentUse it forFor example
CheckboxA yes/no choice, or one of several independent options, that applies when the form is submitted“I agree to the terms”, “Email me updates”
SwitchA setting that takes effect the moment it is flipped, with no Save button“Dark mode”, “Wi-Fi”, “Notifications on”
ToggleA 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.

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.

<Checkbox.Root><Checkbox.Indicator /></Checkbox.Root>

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.

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.

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>
)}
/>
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 props
PropTypeDefaultDescription
checkedboolean | 'indeterminate'Controlled state. Provide onCheckedChange alongside it. Never pass undefined — the mode is fixed at mount, so write checked={x ?? false}.
defaultCheckedboolean | 'indeterminate'falseInitial state when uncontrolled. Read once, at mount.
onCheckedChange(checked: boolean | 'indeterminate') => voidCalled with the value being moved to, in both controlled and uncontrolled modes. A click never moves to indeterminate — only a parent can set it.
disabledbooleanfalsePrevents activation.
namestringSubmits with the enclosing form when checked. An unchecked or indeterminate checkbox contributes nothing, mirroring a native checkbox.
valuestring'on'Value submitted when checked.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

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

  • A native <button role="checkbox">, so focus, Space and Enter come from the platform rather than from JavaScript. aria-checked is true, false or mixed — the ARIA spelling of the third state.

  • disabled is the button’s own attribute, so the platform removes it from the tab order and suppresses activation. data-disabled is 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, so form.reset() and form.elements treat 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, indeterminate included. 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.Indicator is aria-hidden — the Root already announces the state.

  • The mark transition is disabled under prefers-reduced-motion.

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.