Choice Cards
Card-style radio and checkbox labels where the whole card acts as a clickable target.
Class reference
| Class | Kind | Description |
|---|---|---|
.choice-card | Component | Card-style <label> around a native control. |
.chip | Modifier | Compact pill form: filter chips, tags. |
.primary / .secondary | Intent | Accent for the selected state. |
- Use
.choice-cardon a<label>wrapping a native<input type="radio">or<input type="checkbox">. The input is visually hidden; in browsers without:has()it stays visible so selection state never disappears. - The control shows before any choice: a ring (radio) or a rounded box (checkbox) on the leading edge, level with the first line. Checked, it fills with a check, and the card takes a primary border and a subtle tint.
- Each child element of the label sits on its own row beside the indicator — a title and a
.muteddetail need no wrapper. Wrap loose text in an element. - Stack cards for a list of options (a person, an address, a delivery method), or pair them with grid utilities for options compared side by side.
- The cards already frame each option, so group them in a
.field-group.flush: the fieldset keeps its legend and semantics without drawing a second box around the cards. - Supports disabled, checked, focus-visible, and invalid states; the resting ring turns danger with the border.
<fieldset class="field-group flush stack">
<legend class="field-label">Deliver to</legend>
<label class="choice-card">
<input type="radio" name="address" value="home" checked />
<strong>Home</strong>
<span class="muted">12 Rue des Tanneurs, 1000 Brussels</span>
</label>
<label class="choice-card">
<input type="radio" name="address" value="work" />
<strong>Work</strong>
<span class="muted">Avenue Louise 54, 1050 Ixelles</span>
</label>
<label class="choice-card">
<input type="radio" name="address" value="pickup" />
<strong>Pickup point</strong>
<span class="muted">Collect within 7 days</span>
</label>
</fieldset>
<div class="grid-3">
<label class="choice-card">
<input type="radio" name="cpu" value="1" checked />
<strong>8-core CPU</strong>
<span class="muted">32 GB RAM</span>
</label>
<label class="choice-card">
<input type="radio" name="cpu" value="2" />
<strong>6-core CPU</strong>
<span class="muted">24 GB RAM</span>
</label>
<label class="choice-card">
<input type="radio" name="cpu" value="3" />
<strong>4-core CPU</strong>
<span class="muted">16 GB RAM</span>
</label>
</div>
<fieldset class="field-group">
<legend class="field-label">Features</legend>
<div class="grid-3">
<label class="choice-card">
<input type="checkbox" name="features" value="wifi" checked />
<strong>WiFi</strong>
<span class="muted">Dual-band 2.4/5 GHz</span>
</label>
<label class="choice-card">
<input type="checkbox" name="features" value="bluetooth" />
<strong>Bluetooth</strong>
<span class="muted">5.3, low energy</span>
</label>
<label class="choice-card">
<input type="checkbox" name="features" value="nfc" disabled />
<strong>NFC</strong>
<span class="muted">Coming soon</span>
</label>
</div>
</fieldset>
Validation follows the shared pattern: aria-invalid="true" on the input (set manually or by the .needs-validation enhancer) turns the card border to danger, and a .field-error inside a .field wrapper is shown.
<fieldset class="field field-group">
<legend class="field-label">Plan</legend>
<div class="grid-3">
<label class="choice-card">
<input type="radio" name="plan" value="starter"
aria-invalid="true" aria-describedby="plan-error" />
<strong>Starter</strong>
<span class="muted">1 project</span>
</label>
<label class="choice-card">
<input type="radio" name="plan" value="pro"
aria-invalid="true" aria-describedby="plan-error" />
<strong>Pro</strong>
<span class="muted">Unlimited projects</span>
</label>
<label class="choice-card">
<input type="radio" name="plan" value="team"
aria-invalid="true" aria-describedby="plan-error" />
<strong>Team</strong>
<span class="muted">Unlimited + SSO</span>
</label>
</div>
<span class="field-error" id="plan-error" role="alert">
Choose a plan to continue.
</span>
</fieldset>
Sizes follow the shared .sm and .lg typography scale; the check indicator
follows through its em sizing. Padding is density, so use a .compact or
.spacious context to change it. Intent classes (.primary, .secondary) are
supported on the .choice-card element.
Corner badge
A tile whose content is the choice itself — a product image, a centred plan
name — may want no indicator column, and a check badge over its corner once
selected (radio/checkbox tile, selected tick, check in the corner). That is a
recipe on the card's own ::before (disc) and ::after (check) layers,
written in application CSS after the framework:
.choice-tile {
--choice-card-check-size: 1.5em;
display: block;
}
.choice-tile::before,
.choice-tile::after {
position: absolute;
inset-inline-end: -0.375em;
inset-block-start: -0.375em;
margin: 0;
}
.choice-tile::before {
border: 0;
box-shadow: 0 0 0 2px var(--surface-opaque);
transform: scale(0);
}
.choice-tile:has(:checked)::before {
transform: scale(1);
}
<label class="choice-card choice-tile">
<input type="radio" name="cpu" value="1" checked />
<strong>8-core CPU</strong><br />
<span class="muted">32 GB RAM</span>
</label>
The badge overhangs the corner, so no ancestor up to the grid may clip with
overflow: hidden. The Neon Ramen item page
uses it for its sizes and toppings.
Chips
Add .chip for the compact pill form: filter chips, selectable tags, toggle
pills, multi-select facets. A radio group gives single-choice chips. A leading
dot turns into the check in a fixed slot, so checking never moves the label.
Use .cluster for a wrapping row. A chip is sized like a control: .btn and
.input height, --control-pad-x inline padding, .sm / .lg scale.
A chip is a <label> around its checkbox/radio — never nest a .close or any
other button inside it. A removable value (combobox tag) belongs to the widget
that produced it, or to a sibling .badge + .close, not to the chip label.
<fieldset class="field-group">
<legend class="field-label">Fruit</legend>
<div class="cluster">
<label class="choice-card chip">
<input type="checkbox" name="fruit" value="pear" checked />
Pear
</label>
<label class="choice-card chip">
<input type="checkbox" name="fruit" value="apple" />
Apple
</label>
<label class="choice-card chip">
<input type="checkbox" name="fruit" value="plum" disabled />
Plum
</label>
</div>
</fieldset>
The dot is ::before: a full-chip layer clipped to a circle() centred on
the check slot (--choice-chip-dot). A theme can grow it into a selected fill
by widening the clip, as the material theme does.
The check uses --state-selected-text when the theme sets it, including on
chips with an explicit intent. Otherwise it follows the chip's intent.
CSS hooks
--choice-card-pad— inner padding.--choice-card-gap— space between the indicator column and the label.--choice-card-radius— corner radius.--choice-card-check-size— size of the indicator, and the width of its column.--choice-card-border/--choice-card-bg— resting border and background; the checked state and hover rebind them, so a checked override needs:has(:checked).--choice-chip-dot— read-only: the chip's check-slot centre as acircle()position. It is declared on the chip's::before, so read it in a.choice-card.chip…::beforerule.
Plain .check / .radio controls expose two hooks:
--choice-control-size— the control's own size, inemoff the surrounding type. The switch track derives from it too, so a checkbox and a switch on the same line stay proportionate. A.choice.smor.choice.lgscales it through the surrounding typography.--choice-control-nudge— block correction added to the derived first-line offset, for every control in the row. Left unset, each control centres itself on the first line's cap height, measured from its own block size — so a checkbox and a taller switch stay level with each other, and with the text, whatever the font, line-height or control size.