Checkbox
Independent on/off choices, staged until submit. The indeterminate state is what makes a parent checkbox honest about a partial selection.
Also called Tickbox, Check Input — in this system all of them are Checkbox.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Parent and children
The parent is checked when all children are, indeterminate when some are, and unchecked when none are. Clicking it selects all or clears all — never sets indeterminate.
A checkbox group
A real fieldset with a legend, so the group name is announced before every option. Descriptions sit under their label, not beside it.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
The label and description are inside the clickable region, which is what turns an 18px target into a comfortable one.
- Box size18 × 18px
The smallest square where a checkmark reads as a checkmark. 16px is available as the small size for dense tables and nowhere else.
- Corner radius4px · --radius-xs
Square-ish on purpose. A round checkbox reads as a radio, and users answer the wrong question.
- Box to label gap10px
Wide enough that the checkmark and the first letter do not visually merge; narrow enough that they stay one unit.
- Optical offset2px from the top
The box aligns with the cap height of the first line, not the line box. Centring on the line box drops it visibly low.
- Checkmark13px, 3.2 stroke
Heavier stroke than a normal icon. At 13px a 1.75 stroke disappears against a saturated fill.
- Check animationscale 0.5 → 1, 140ms
The mark scales up from the centre as the fill lands. It reads as the box accepting the input rather than the mark being pasted on.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-field | — | Unchecked box fill — a control goes above its container, never below it |
| --ds-border-strong | — | Unchecked box border |
| --ds-accent | — | Checked fill and border |
| --ds-accent-fg | — | The checkmark |
| --ds-accent-subtle | — | Hover fill |
| --ds-danger | — | Error border |
| --ds-focus-ring | — | Focus outline |
Spacing
| Token | Value | Used for |
|---|---|---|
| gap | Box to label | |
| stack gap | Between checkboxes in a group |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xs | Box corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-body-sm | — | Label |
| --text-caption | — | Description |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Fill transition, checkmark scale |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Radius | Icon | Label gap | Type | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|
| Small | 16px | 4px | 11px | 10px | 13px | — | 44px (row) | Table row selection and dense lists only. |
| Medium | 18px | 4px | 13px | 10px | 13px | — | 44px (row) | The default everywhere else. |
| With description | 18px | — | — | 10px | 13px / 12px | 60ch | — | Adds a second line. Keep the description to one line where possible. |
<div className="checkbox" onClick={toggle} />Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The unchecked border must reach 3:1 against the surface — it is the only thing showing the control exists.
- The checkmark must reach 3:1 against the checked fill. White on our accent is 4.6:1 in dark and 5.7:1 in light.
- Never signal a checked state with fill colour alone. The checkmark is the redundant encoding.
Keyboard
| Tab | Moves to each checkbox. Unlike radios, every checkbox in a group is tabbable. |
| Space | Toggles. Enter does not activate a checkbox — that is native behaviour, not a bug. |
Screen readers
- Announced as "checkbox, checked" or "checkbox, not checked". Indeterminate announces as "mixed".
- The description must be wired with aria-describedby — visual proximity means nothing to a screen reader.
- For a parent/child tree, announce the count in the parent’s description: "3 of 5 selected".
Focus & touch
- The ring is on the box, not the whole row. A ring around the full label block makes it ambiguous which of several stacked options is focused.
- The clickable row is at least 44px tall on coarse pointers even though the box is 18px. Adjacent checkboxes need 8px of clear space between their rows.
| Attribute | Applied to | Notes |
|---|---|---|
| <input type="checkbox"> | The control | Native gives role, state, keyboard and form participation with zero ARIA. |
| <label for> | The label | Provides the accessible name and extends the hit area. Not optional. |
| indeterminate | The DOM property | There is no HTML attribute. It must be set in JavaScript, and it maps to aria-checked="mixed". |
| aria-describedby | The input | Points at the description, so it is announced after the label. |
| aria-invalid | The input | For a required consent box that was not ticked. |
| <fieldset> + <legend> | A group | The legend is announced before every option, giving each one its context. |
Example usage
1import { Checkbox } from '@/ui/Toggle'23// Basic4<Checkbox5 label="Email me about security alerts"6 description="Sent immediately, never batched."7 checked={value}8 onChange={(e) => setValue(e.target.checked)}9/>1011// Parent / child. Indeterminate is derived, never stored.12const all = selected.length === options.length13const some = selected.length > 0 && !all1415<Checkbox16 checked={all}17 indeterminate={some}18 onChange={() => setSelected(all ? [] : options.map((o) => o.id))}19 label="All permissions"20 description={selected.length + ' of ' + options.length + ' selected'}21/>2223// Group: a real fieldset, so the legend is announced with every option24<fieldset>25 <legend>Notify me when</legend>26 {options.map((o) => (27 <Checkbox28 key={o.id}29 label={o.label}30 checked={selected.includes(o.id)}31 onChange={() => toggle(o.id)}32 />33 ))}34</fieldset>Framework-free HTML
<div class="ds-checkbox">
<input class="ds-checkbox__input" type="checkbox" id="alerts"
name="alerts" aria-describedby="alerts-desc" checked />
<span class="ds-checkbox__mark" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7" /></svg>
</span>
<label class="ds-checkbox__label" for="alerts">
Email me about security alerts
</label>
<p class="ds-checkbox__desc" id="alerts-desc">Sent immediately, never batched.</p>
</div>
<!-- Indeterminate has no attribute. It must be set in JavaScript: -->
<script>
document.getElementById('all').indeterminate = true
</script>CSS
.ds-checkbox__input {
appearance: none; /* keep the element, drop the paint */
inline-size: 18px;
block-size: 18px;
border: 1px solid var(--ds-border-strong);
border-radius: var(--radius-xs);
/* The control rung, not the well one. On --ds-surface-inset an unchecked
box sits below the card holding it and reads as switched off. */
background: var(--ds-field);
transition:
background-color 120ms var(--ease-standard),
border-color 120ms var(--ease-standard);
}
.ds-checkbox__input:hover:not(:disabled) {
border-color: var(--ds-accent);
background: var(--ds-accent-subtle);
}
.ds-checkbox__input:checked,
.ds-checkbox__input:indeterminate {
background: var(--ds-accent);
border-color: var(--ds-accent);
}
.ds-checkbox__input:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: 2px;
}
/* The mark scales in as the fill lands */
.ds-checkbox__mark {
opacity: 0;
transform: scale(0.5);
transition: all 140ms var(--ease-emphasized);
color: var(--ds-accent-fg);
}
.ds-checkbox__input:checked + .ds-checkbox__mark,
.ds-checkbox__input:indeterminate + .ds-checkbox__mark {
opacity: 1;
transform: scale(1);
}
/* Align the box to the cap height, not the line box */
.ds-checkbox__input { margin-block-start: 2px; }Component API
Checkbox
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | Rendered as a real <label for>. Extends the hit area. |
| description | ReactNode | — | Second line, wired with aria-describedby. |
| checked | boolean | — | Controlled. Omit for uncontrolled with defaultChecked. |
| indeterminate | boolean | false | Sets the DOM property. Derive it; never store it as a third value. |
| size | 'sm' | 'md' | 'md' | 16px or 18px box. |
| error | boolean | false | Red border plus aria-invalid. |
| disabled | boolean | false | Dims the whole row, including the label. |
Professional tips
- Order options by likelihood or by an existing convention, not alphabetically. Alphabetical order is only correct when the user already knows exactly what they are looking for.
- For a "select all" that spans pages, say what it selects: "All 25 on this page" and "All 1,432 matching" are different actions and must be separate controls.
- A required consent checkbox should be validated on submit, not on blur. Blurring a checkbox the user has not decided about yet is not a mistake.
- Keep descriptions to one line. A checkbox with a paragraph attached is a decision that deserves a RadioCard or its own section.
Performance
- For a table with thousands of selectable rows, store selection in a Set and virtualise. Rendering ten thousand inputs blocks the main thread for hundreds of milliseconds.
- Derive indeterminate during render rather than storing it. A stored third state inevitably drifts out of sync with the children.
- Avoid a state update per checkbox in a large group — batch into one array update so the group re-renders once.
Common mistakes
- Trying to set indeterminate as a JSX attribute. React passes unknown attributes through to the DOM as strings; it must be assigned as a property in an effect.
- Using Enter to toggle. Native checkboxes respond to Space only, and overriding that surprises keyboard users.
- Putting the label before the box in the DOM to get a right-aligned layout. Use flex ordering instead, so the reading order stays correct.
- Making the entire row a click target including a nested link, so clicking the link also toggles the checkbox.
Real-world recommendations
- In permission and scope UIs, show the effective result of the selection as plain text underneath. Users routinely misjudge what a combination of scopes actually grants.
- For terms and conditions, put the checkbox after the text and keep the link opening in a new tab. Navigating away mid-form loses everything the user typed.
- Log which options in a group are never selected. An option nobody chooses is either badly labelled or should not exist.
- When a table has both row selection and row navigation, keep the checkbox column separate and stop propagation on it. Otherwise selecting a row navigates away from it.