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
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
| Size | Circle | Dot | Label | Use case |
|---|---|---|---|---|
| Small | 16px | 6px | 13px | Dense settings lists, filter drawers |
| Default | 20px | 8px | 15px | Standard — split mode, category, currency |
| Large | 24px | 10px | 17px | Onboarding selection screens |
States
| State | Circle | Dot | Transition |
|---|---|---|---|
| Unselected | --ink-line border, --s-paper bg | Hidden (scale(0)) | — |
| Hover | Border shifts to --teal | Hidden | 140ms ease-out |
| Selected | --teal fill | White dot, scale(1) | Spring 140ms |
| Focused | 2px --teal outline, 2px offset | — | Instant |
| Disabled | 40% opacity, warm bg, no cursor | Hidden 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
| Part | Element | Notes |
|---|---|---|
| Input | input[type=radio].radio-input | Visually hidden. Drives all state via :checked and :disabled CSS selectors. |
| Label | label.radio-label | Full tap target. for attribute links to input ID. |
| Circle | span.radio-circle | 20px, custom drawn. ::after pseudo-element is the selection dot. |
| Label text | span.radio-label__text | Optional wrapper for two-line label + description layout. |
| Description | span.radio-label__desc | Secondary 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
| Class | Purpose |
|---|---|
.radio-input | Visually hidden native input — drives all state |
.radio-label | Full tap target, flex row with circle and text |
.radio-circle | Custom 20px circle, ::after is the selection dot |
.radio-label__text | Column flex wrapper for two-line labels |
.radio-label__desc | Secondary description — 13px, muted |
.radio-group | Vertical stack, 12px gap |
.radio-group--horizontal | Horizontal wrap, 16px gap |
.radio-item | Wrapper per option — holds input + label |
Design tokens used
| Token | Value | Role |
|---|---|---|
--teal | #0E7C66 | Selected circle fill and border hover |
--teal-deep | #0A5F4F | Selected hover circle fill |
--ink-line | #DDD2B8 | Unselected circle border |
--s-paper | #FBF8F0 | Unselected circle bg, selection dot color |
--s-warm | #F1E9D5 | Disabled circle bg |
--ease-spring | cubic-bezier(0.34,1.56,0.64,1) | Dot appear animation |
--d-fast | 140ms | All transitions |