Components · Choicebox
Components

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.

Which distance did you run?

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.

What should we send you?

Specification

PropertyValueWhy
InputNative radio or checkbox, clipped but focusableKeyboard, form participation and grouping come free
TargetThe entire boxThe point of the component; a 44px floor is met many times over
Resting1px --color-border, --radius-mdA card, and the system's cards are hairlines
Selected--color-primary border + --color-primary-subtle fill + a checkThree cues; the check is the one that survives greyscale
FocusSystem ring on the box, via :has(:focus-visible)The input is clipped, so the box shows the focus
Title--text-ui, weight 600Scannable before the description is read
Description--text-sm, --color-text-secondary, max 2 linesPast two lines it is content, and this is a control
Count2–5 optionsSix 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} />
Sporting Scouter Design System
Every token on this site is generated from theme.css.