Choicebox
A choicebox is a radio or a checkbox with the whole card as its target. It exists for the two or three moments where the options need explaining — the ones where a bare radio list would make someone guess what the difference is.
Single select
Radio semantics: one of the set, in a <fieldset>. Arrow keys move between options; Tab enters and leaves the group as one stop.
Multi select
Same component, checkbox input. Use it when the options need a sentence each; when they do not, a checkbox list is smaller, faster and just as clear.
Specification
| Property | Value | Why |
|---|---|---|
| Input | Native radio or checkbox, clipped but focusable | Keyboard, form participation and grouping come free |
| Target | The entire box | The point of the component; a 44px floor is met many times over |
| Resting | 1px --color-border, --radius-md | A card, and the system's cards are hairlines |
| Selected | --color-primary border + --color-primary-subtle fill + a check | Three cues; the check is the one that survives greyscale |
| Focus | System ring on the box, via :has(:focus-visible) | The input is clipped, so the box shows the focus |
| Title | --text-ui, weight 600 | Scannable before the description is read |
| Description | --text-sm, --color-text-secondary, max 2 lines | Past two lines it is content, and this is a control |
| Count | 2–5 options | Six or more is a Select — a wall of cards is not a choice |
Structure
<fieldset>
<legend>Which distance did you run?</legend>
<label class="choicebox">
<input type="radio" name="distance" value="42">
<span class="choicebox__box">
<span class="choicebox__title">Marathon</span>
<span class="choicebox__desc">42.2 km · 4 h 12 m average</span>
<span class="choicebox__tick" aria-hidden="true"><!-- lucide:check --></span>
</span>
</label>
</fieldset>The input is clipped with a 1px inset rect, not display:none and not visibility:hidden — both remove it from the tab order. The box reacts with .choicebox:has(:checked) and .choicebox:has(:focus-visible).
Rules
- The description must differentiate. If every card's second line could be swapped between cards without anyone noticing, delete all of them — that is the Deletion Test applied to a control.
- No pricing theatre. No “Most popular” ribbon, no pre-selected upsell, no card scaled larger than its neighbours. Options are presented at equal weight.
- Equal height, always. Cards in a row stretch to match; a short description does not produce a short card.
- Selection is instant and reversible — no confirm step, no dialog. If choosing costs something, that belongs on the submit, not the card.
- Do not nest controls. A button, link or second input inside a choicebox creates two targets in one, and the outer label swallows the inner click.
- Never colour alone. The check mark is not decoration; it is the redundant cue Principles requires.
React
From @sportingscouter/ui-react. The component adds no visual values of its own — every class comes from the same tv() recipe the design system defines, so the two cannot drift.
import { Choicebox } from '@sportingscouter/ui-react';
<Choicebox
legend="Which distance did you run?"
columns={2}
options={[
{ value: '42k', title: 'Marathon',
description: '42.2 km', aside: '€65' },
{ value: '21k', title: 'Half marathon',
description: '21.1 km', aside: '€40' },
{ value: '10k', title: '10K',
description: 'Chip-timed', aside: '€22' },
{ value: 'kids', title: 'Kids race',
description: '1 km, under 12', disabled: true },
]}
onValueChange={setDistance}
/>
{/* multiple swaps the indicator to a square — the shape is
the only reliable signal of whether picking one clears
the others. */}
<Choicebox legend="Add-ons" multiple options={addons} />theme.css.