Radio

Custom radio button for single-selection from a group. A 20px circle with a teal filled dot when selected. Used primarily for split mode selection — equal, unequal, percentage, shares — and for category or currency pickers in settings.

Variants

Split mode

The label sits to the right of the circle. A secondary description line can be added for clarification — keep it short, one line at most. The whole label area is the tap target, not just the circle.

The selected dot springs in with --ease-spring, giving a satisfying bounce that matches the physical feeling of clicking a radio button.

Sizes

SizeCircleDotLabelUse case
Small16px6px13pxDense settings lists, filter drawers
Default20px8px15pxStandard — split mode, category, currency
Large24px10px17pxOnboarding selection screens

States

StateCircleDotTransition
Unselected--ink-line border, --s-paper bgHidden (scale(0))
HoverBorder shifts to --tealHidden140ms ease-out
Selected--teal fillWhite dot, scale(1)Spring 140ms
Focused2px --teal outline, 2px offsetInstant
Disabled40% opacity, warm bg, no cursorHidden or frozen

On dark surfaces

On dark surfaces, the unselected circle uses --dark-elevated background and --dark-border border. The selected teal fill is unchanged — it contrasts well on both surfaces.

Anatomy

Circle (20px)
Dot (8px, white)
Equal split Everyone pays the same
Label + description
PartElementNotes
Inputinput[type=radio].radio-inputVisually hidden. Drives all state via :checked and :disabled CSS selectors.
Labellabel.radio-labelFull tap target. for attribute links to input ID.
Circlespan.radio-circle20px, custom drawn. ::after pseudo-element is the selection dot.
Label textspan.radio-label__textOptional wrapper for two-line label + description layout.
Descriptionspan.radio-label__descSecondary line. 13px, muted color. Optional.

Usage

Do Use radios when the user must pick exactly one option from 2–6 choices. Wrap in a <fieldset> with a <legend> to group them semantically. Default to the most common choice pre-selected.
Don't Don't use radio for more than 6 options — use a select dropdown instead. Don't use radio for on/off settings — use a toggle. Don't put a single radio in isolation — it implies a group.
Do Use the two-line label (label + description) for split modes where a one-word label would be ambiguous. "By percentage — set % for each person" is clearer than "Percentage."
Don't Don't put more than two lines of text next to a radio circle. If you need that much explanation, use a card-style option selector instead. Don't use sentence case for the label — use title case sparingly, prefer sentence case.

Accessibility

Keyboard Radio groups are navigated with arrow keys once focused — Up/Down cycle through options. Tab moves to the next form element. The visually hidden <input> handles all native keyboard behavior.
Grouping Always wrap a radio group in <fieldset> with a <legend>. The legend is announced by screen readers as the group label. If using a visual heading instead, add aria-labelledby to the fieldset pointing to the heading ID.
Focus ring The focus ring appears on the custom circle via :focus-visible on the hidden input. Never remove it — keyboard users depend on it for orientation.

Code

HTML

<!-- Radio group -->
<fieldset>
  <legend>Split mode</legend>

  <div class="radio-group">
    <!-- Option with description -->
    <div class="radio-item">
      <input class="radio-input" type="radio" name="split" id="split-equal" checked>
      <label class="radio-label" for="split-equal">
        <span class="radio-circle"></span>
        <span class="radio-label__text">
          <span>Equal split</span>
          <span class="radio-label__desc">Everyone pays the same</span>
        </span>
      </label>
    </div>

    <!-- Disabled option -->
    <div class="radio-item">
      <input class="radio-input" type="radio" name="split" id="split-shares" disabled>
      <label class="radio-label" for="split-shares">
        <span class="radio-circle"></span>
        <span>By shares</span>
      </label>
    </div>
  </div>
</fieldset>

CSS classes

ClassPurpose
.radio-inputVisually hidden native input — drives all state
.radio-labelFull tap target, flex row with circle and text
.radio-circleCustom 20px circle, ::after is the selection dot
.radio-label__textColumn flex wrapper for two-line labels
.radio-label__descSecondary description — 13px, muted
.radio-groupVertical stack, 12px gap
.radio-group--horizontalHorizontal wrap, 16px gap
.radio-itemWrapper per option — holds input + label

Design tokens used

TokenValueRole
--teal#0E7C66Selected circle fill and border hover
--teal-deep#0A5F4FSelected hover circle fill
--ink-line#DDD2B8Unselected circle border
--s-paper#FBF8F0Unselected circle bg, selection dot color
--s-warm#F1E9D5Disabled circle bg
--ease-springcubic-bezier(0.34,1.56,0.64,1)Dot appear animation
--d-fast140msAll transitions