Checkbox and radio

.check goes on a <label> that wraps a real <input type="checkbox"> or <input type="radio">. The input keeps every behaviour the browser gives it — space to toggle, arrow keys to move within a radio group, form submission — and appearance: none only replaces how it looks.

When to use it

Checkboxes for any number of independent choices; radios for exactly one of a small set. The row is min-block-size: var(--tap) — 44px — and the label wraps the input, so the whole line is the target rather than the 20px box.

Wrapping rather than using for/id is deliberate: it cannot drift. A for that points at a missing id silently produces an unlabelled control, and nothing warns you.

The label is the row; the whole row is clickable
<label class="check"> <input type="checkbox" checked> <span>Email me when an export finishes</span> </label> <label class="check"> <input type="checkbox"> <span>Email me about product news</span> </label>
<label class="check">
  <input type="checkbox" checked>
  <span>Email me when an export finishes</span>
</label>
<label class="check">
  <input type="checkbox">
  <span>Email me about product news</span>
</label>

How the tick is drawn

There is no icon and no background image. The ::before is a solid block coloured by box-shadow: inset 1em 1em var(--text-on-brand) and then cut into a tick by a six-point clip-path. It scales from 0 to 1 on :checked, which is why the tick springs in rather than appearing.

The radio uses the same trick with a circle instead of a polygon, and the indeterminate state reuses the checkbox's ::before with a rectangular clip — one dash, no third asset.

Three states, no images
<label class="check"> <input type="checkbox"> <span>Unchecked</span> </label> <label class="check"> <input type="checkbox" checked> <span>Checked</span> </label> <label class="check"> <input type="checkbox" disabled> <span>Disabled</span> </label>
<label class="check">
  <input type="checkbox">
  <span>Unchecked</span>
</label>
<label class="check">
  <input type="checkbox" checked>
  <span>Checked</span>
</label>
<label class="check">
  <input type="checkbox" disabled>
  <span>Disabled</span>
</label>

Radios

Same class. The only difference in the stylesheet is border-radius: var(--r-full) on the box and a circle instead of a tick. Give every radio in a group the same name — that is what makes them mutually exclusive and what makes the arrow keys move between them.

Arrow keys move between them because they share a name
<label class="check"> <input type="radio" name="ex-plan" checked> <span>Monthly</span> </label> <label class="check"> <input type="radio" name="ex-plan"> <span>Annual</span> </label>
<label class="check">
  <input type="radio" name="ex-plan" checked>
  <span>Monthly</span>
</label>
<label class="check">
  <input type="radio" name="ex-plan">
  <span>Annual</span>
</label>

A radio group needs a group label as well as per-option labels. Wrap it in a <fieldset class="fieldset"> with a <legend>, or the reader hears "Monthly, radio button" with no indication of what is being chosen.

Indeterminate

:indeterminate is a real CSS pseudo-class and Deck styles it: filled box, horizontal dash. It cannot be set in HTML — only el.indeterminate = true in JavaScript — which is why there is no attribute in the example below and why a "select all" checkbox needs a line of script to show a partial selection.

<label class="check">
  <input type="checkbox" id="all">
  <span>Select all</span>
</label>

<script>
  document.getElementById('all').indeterminate = true;
</script>

Indeterminate is a display state only. The checkbox still submits as checked or unchecked, so the server never sees "partial".

Two-line labels

.check-text makes the text a column so a .check-note can sit under the label as a second, muted line. The row uses align-items: flex-start and the box gets margin-block-start: 1px, so the box aligns with the first line of text rather than centring against a two-line block.

The box stays on the first line
<label class="check"> <input type="checkbox" checked> <span class="check-text"> <span>Nightly backups</span> <span class="check-note">Kept for 30 days. Adds $4 per month.</span> </span> </label>
<label class="check">
  <input type="checkbox" checked>
  <span class="check-text">
    <span>Nightly backups</span>
    <span class="check-note">Kept for 30 days. Adds $4 per month.</span>
  </span>
</label>

Choice cards

.check-card adds a border, padding and a surface to the same label, and uses :has(input:checked) to light up the whole card when the control inside it is chosen. No JavaScript and no state class — the parent selector does it.

Select one: the card border follows
<div class="grid grid-tight"> <label class="check check-card"> <input type="radio" name="ex-tier" checked> <span class="check-text"> <span>Team</span> <span class="check-note">Up to 20 people, shared billing.</span> </span> </label> <label class="check check-card"> <input type="radio" name="ex-tier"> <span class="check-text"> <span>Enterprise</span> <span class="check-note">SSO, audit log, and a contract.</span> </span> </label> </div>
<div class="grid grid-tight">
  <label class="check check-card">
    <input type="radio" name="ex-tier" checked>
    <span class="check-text">
      <span>Team</span>
      <span class="check-note">Up to 20 people, shared billing.</span>
    </span>
  </label>
  <label class="check check-card">
    <input type="radio" name="ex-tier">
    <span class="check-text">
      <span>Enterprise</span>
      <span class="check-note">SSO, audit log, and a contract.</span>
    </span>
  </label>
</div>

:has() is in every browser Deck targets. In one that lacks it the card still works — it just does not highlight, and the radio inside it still shows which option is chosen.

Switch

.switch is the same pattern — a <label> wrapping a real <input type="checkbox"> — drawn as a 46×28 track with a 22px knob in an ::after. The knob moves with translate: 18px 0 on :checked, and its offset is inset-inline-start, so under dir="rtl" it starts on the right and travels the other way without a second rule.

A checkbox underneath; a switch on the surface
<label class="switch"> <input type="checkbox" checked> <span>Two-factor authentication</span> </label> <label class="switch"> <input type="checkbox"> <span>Weekly digest</span> </label>
<label class="switch">
  <input type="checkbox" checked>
  <span>Two-factor authentication</span>
</label>
<label class="switch">
  <input type="checkbox">
  <span>Weekly digest</span>
</label>

A switch is announced as a checkbox, because it is one. If it reads better as "on"/"off" than "checked"/"unchecked", add role="switch" to the input — the browser then exposes it as a switch and reports its state accordingly. Deck does not add the role for you, because a switch inside a form that is saved with a button really is a checkbox and should stay one.

In a data grid

src/12-datagrid.css reuses .check for the row-selection column, stripping the row's min-block-size so a selection checkbox does not force every grid row to 44px. That is the one place Deck deliberately drops the touch target, and it is a trade the data grid makes knowingly: a dense grid of 34px rows cannot also have 44px checkboxes.

Classes

Generated from src/06-forms.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.check deck.components A checkbox or radio and its text, laid out as a row that clears the 44px touch target. Wrap the text in .check-text to add a second line. 06-forms.css:175
.check-card deck.components Card-style choice, good for pricing and plan pickers 06-forms.css:238
.check-note deck.components No declarations. 06-forms.css:235
.check-text deck.components No declarations. 06-forms.css:231

Tokens

Design tokens this component reads
Token Live Declared value
--tap 44px
--surface light-dark(var(--ink-0), var(--ink-950))
--line light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral)))
--line-strong light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral)))
--brand-400 oklch(70.2% calc(var(--chroma-brand) * .90) var(--hue-brand))
--brand-500 oklch(60.8% var(--chroma-brand) var(--hue-brand))
--brand-600 oklch(51.6% var(--chroma-brand) var(--hue-brand))
--brand-soft light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand)))
--text-on-brand oklch(99% .01 var(--hue-brand))
--focus light-dark(var(--brand-500), var(--brand-300))
--r-xs 4px
--r-full 999px
--r-md 12px
--text-sm .8125rem
--text-muted light-dark(var(--ink-600), var(--ink-400))
--space-2 .5rem
--space-3 .75rem
--space-4 1rem
--dur-1 110ms
--dur-2 190ms

Accessibility

  • The input is real. appearance: none changes painting only. Space toggles, arrows move within a radio group, the control is announced as a checkbox or radio button, and it submits with the form. Nothing here is a <div> with a click handler.
  • Wrapping is the labelling. The <label class="check"> contains the input, so the association cannot break. The whole row is clickable as a consequence, not as a separate feature.
  • Focus is a real outline. :focus-visible draws 2px solid var(--focus) with a 2px offset on the box itself, so the ring is on the control rather than around the whole row.
  • Touch target. The box is 20px; the row is 44px. Because the label wraps the input, the target is the row. The exception is inside a data grid — see In a data grid.
  • A radio group needs a <fieldset> and a <legend>. Without one the options are announced individually with no question attached. This is the most common radio defect and no class can fix it.
  • .check-note is not announced as part of the label. It is inside the <label>, so it is read as part of the accessible name — which makes for a long name like "Nightly backups Kept for 30 days Adds $4 per month". That is usually acceptable and occasionally too much; move the note outside the label and use aria-describedby if it is.
  • Indeterminate is announced as "mixed" by most screen readers, but only because the browser exposes the property. Setting a class instead of the property styles it without announcing it.
  • Contrast. The unchecked box is --line-strong on --surface, clearing the 3:1 minimum for a control boundary. The tick is --text-on-brand on --brand-600, computed rather than hard-coded, so it survives a change of brand hue.

Right to left

The row is a flex line with gap, so the box moves to the right of the text under dir="rtl" on its own. Nothing about the tick, the dot or the card is directional — a clip-path tick is symmetrical enough that mirroring it would be indistinguishable.

The box follows the writing direction
<div dir="rtl" class="stack stack-2"> <label class="check"> <input type="checkbox" checked> <span>النسخ الاحتياطي الليلي</span> </label> <label class="check"> <input type="radio" name="ex-rtl-plan" checked> <span>شهري</span> </label> </div>
<div dir="rtl" class="stack stack-2">
  <label class="check">
    <input type="checkbox" checked>
    <span>النسخ الاحتياطي الليلي</span>
  </label>
  <label class="check">
    <input type="radio" name="ex-rtl-plan" checked>
    <span>شهري</span>
  </label>
</div>

Reduced motion

The tick and the dot scale in over 190ms with a spring easing, and the box transitions its background and border. Under prefers-reduced-motion: reduce the global reset collapses all of them to .01ms, so the tick appears instantly. It still appears — the state is never hidden from a reader who has asked for less motion, only the travel is removed.

Printing

src/99-print.css gives the box a visible outline on paper and fills a checked one, with print-color-adjust: exact. Without those three rules a printed form would show every option as an identical empty square, which is the worst possible outcome for a form somebody is meant to read back.

Overriding it

@layer app.components {
  /* A larger box without touching the row */
  .check input[type="checkbox"], .check input[type="radio"] {
    inline-size: 24px;
    block-size: 24px;
  }

  /* A denser list of options */
  .check { min-block-size: 0; padding-block: var(--space-1); }
}

Shrinking the row below --tap is the one override to think twice about: it is the whole reason a checkbox is comfortable on a phone.

When not to use it

  • .check versus .switch. A checkbox collects a value that is submitted with a form. A switch turns something on or off immediately. If the reader has to press Save afterwards, it is a checkbox.
  • Not for one-of-three shown inline. Two or three radios are fine; for a compact toggle between mutually exclusive views use .segmented, which reads as a control rather than as a question.
  • Not for more than about seven options. A long radio list is a .select, or a .combo if it is long enough to need filtering.
  • Not as a fake button. A .check-card with the input visually hidden and a click handler is a button that announces itself as a checkbox. If it performs an action, use .btn.
  • Not for a filter chip row. Use .chip, which is sized for a horizontal row and has a removal affordance. A row of .check labels is 44px tall each and wraps badly.