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.
<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.
<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.
<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.
<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.
<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.
<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.
| 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
| 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: nonechanges 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-visibledraws2px 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-noteis 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 usearia-describedbyif 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-strongon--surface, clearing the 3:1 minimum for a control boundary. The tick is--text-on-brandon--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.
<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
-
.checkversus.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.comboif it is long enough to need filtering. -
Not as a fake button. A
.check-cardwith 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.checklabels is 44px tall each and wraps badly.