Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Sent immediately, never batched.

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.

2 of 5 selected

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.

Notify me when
Immediate, to email and Slack.
Seven days before expiry.
Digest, once per day.
Requires the Team plan.

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.

Unchecked
Checked
IndeterminateParent, partial
Hover
Focus
Erroraria-invalid
Disabled
Disabled + checked
Small16px box
With label
Sent immediately.
Two-line
Required

Anatomy

Every part, every measurement, and the reason it is that number.

Sent immediately, never batched.

The label and description are inside the clickable region, which is what turns an 18px target into a comfortable one.

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

  2. Corner radius4px · --radius-xs

    Square-ish on purpose. A round checkbox reads as a radio, and users answer the wrong question.

  3. Box to label gap10px

    Wide enough that the checkmark and the first letter do not visually merge; narrow enough that they stay one unit.

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

  5. Checkmark13px, 3.2 stroke

    Heavier stroke than a normal icon. At 13px a 1.75 stroke disappears against a saturated fill.

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

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
gapBox to label
stack gapBetween checkboxes in a group

Radius

TokenValueUsed for
--radius-xsBox corners

Typography

TokenValueUsed for
--text-body-sm—Label
--text-caption—Description

Motion

TokenValueUsed for
durationFill transition, checkmark scale

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightRadiusIconLabel gapTypeMax widthTouch targetWhen to use
Small16px4px11px10px13px—44px (row)Table row selection and dense lists only.
Medium18px4px13px10px13px—44px (row)The default everywhere else.
With description18px——10px13px / 12px60ch—Adds a second line. Keep the description to one line where possible.

Do

Make the label part of the targetA real <label for> means clicking the text toggles the box. It turns an 18px target into a 200px one at no cost, and it is the single biggest usability difference between a good and a bad checkbox.
not "Unsubscribe from updates"
Write labels as positive statements"Do not send me email" checked means no email — a double negative the user has to unpick. Always phrase the checked state as the thing that happens.
3 of 5 selected
Use indeterminate for a partial parentA parent that shows unchecked when three of five children are selected is lying. Indeterminate is the only honest representation, and users understand it immediately.
Stack verticallyOne left edge means one fixation per option. A horizontal row forces the eye to jump between differing label widths and makes it ambiguous which label belongs to which box.

Don't

…saved instantly? Then it is a switch.
Do not use a checkbox for an instant settingA checkbox implies a pending change. If the value saves the moment it is clicked, a switch communicates that correctly and a checkbox does not.
only one is valid — so use radios
Do not use checkboxes for a single choiceMultiple checkboxes where only one may be selected forces the user to discover the rule by trying it. Radio buttons express it in the shape of the control.
Do not pre-check a consent boxPre-ticked marketing consent is unlawful under GDPR and, more simply, it is not consent. Any box that grants a permission starts unchecked.
<div className="checkbox" onClick={toggle} />
Do not hide the box and style a divA styled div loses keyboard support, form participation, indeterminate, and the native accessibility tree. Keep the real input and style it with appearance: none.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

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

TabMoves to each checkbox. Unlike radios, every checkbox in a group is tabbable.
SpaceToggles. 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.
AttributeApplied toNotes
<input type="checkbox">The controlNative gives role, state, keyboard and form participation with zero ARIA.
<label for>The labelProvides the accessible name and extends the hit area. Not optional.
indeterminateThe DOM propertyThere is no HTML attribute. It must be set in JavaScript, and it maps to aria-checked="mixed".
aria-describedbyThe inputPoints at the description, so it is announced after the label.
aria-invalidThe inputFor a required consent box that was not ticked.
<fieldset> + <legend>A groupThe legend is announced before every option, giving each one its context.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
labelReactNode—Rendered as a real <label for>. Extends the hit area.
descriptionReactNode—Second line, wired with aria-describedby.
checkedboolean—Controlled. Omit for uncontrolled with defaultChecked.
indeterminatebooleanfalseSets the DOM property. Derive it; never store it as a third value.
size'sm' | 'md''md'16px or 18px box.
errorbooleanfalseRed border plus aria-invalid.
disabledbooleanfalseDims the whole row, including the label.

Notes

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.