User Interface (UI)
Let's start with how a radio group looks: its parts, its sizes, how it shows each of its states and how to lay out its options.
Anatomy
- Group — The
<fieldset>, which groups the radios and carries the size. - Legend — The question the radios answer, which names the group.
- Hint — Optional. Help that applies to every option, which describes the group.
- Radio — One option, its indicator and its label, which people can press anywhere.
- Indicator — The native radio, drawn as a ring, which fills with a dot once it's checked.
- Label — The option's text, which names its radio.
Every radio group has a group, a legend and two or more radios. An error message, when there is one, sits between the hint and the radios, so people read what's wrong before they choose again. The radios sit in a box of their own, the options, so they can stack or sit in a row while the hint and the error stay above them. The parts always render in the same order, which you can see in the DOM contract.
Sizes
The size prop scales the text, the indicator, the gap and the height of each row together, and md is the default. The group passes it to its radios, so they're all the same size.
<RadioGroup size="xs" name="delivery" legend="Delivery">
<Radio value="standard">Standard</Radio>
<Radio value="express">Express</Radio>
</RadioGroup>
{/* …sm, md, lg, xl */}
| Size | Row min. height | Text | Indicator | Gap |
|---|---|---|---|---|
xs | 24px | 12px | 14px | 4px |
sm | 32px | 14px | 16px | 8px |
md | 36px | 14px | 16px | 8px |
lg | 40px | 16px | 20px | 8px |
xl | 48px | 16px | 20px | 8px |
Sizes are set in rem, and the table shows the values we recommend at the default text size of 16px. Each row is as tall as an Input of the same size, and its text is the same size too, so a radio lines up with an input beside it, as under Layout.
The indicator on its own is only 14 to 20px, which is why the label is part of the target: pressing it checks the radio, so every radio is at least 24 × 24px with its label (WCAG 2.5.8). On touch screens, each row grows to at least 44px tall. The heights are minimums, so a row grows with larger text, or with a label that wraps.
States
Each state has a look of its own, styled from the attribute that exposes it, on the <input> or on the group. That way, what people see and what assistive technologies report can never drift apart, and a group can't look invalid without actually being invalid.
<RadioGroup
name="delivery"
legend="Delivery"
value={delivery}
onChange={(event) => setDelivery(event.target.value)}
>
<Radio value="standard">Standard</Radio>
<Radio value="express">Express</Radio>
</RadioGroup>
<RadioGroup
name="shipping"
legend="Shipping"
value={shipping}
onChange={(event) => setShipping(event.target.value)}
>
<Radio value="standard">Standard</Radio>
<Radio value="pickup" disabled>Collect from a store</Radio>
</RadioGroup>
<RadioGroup name="plan" legend="Plan" value="team" disabled>
<Radio value="personal">Personal</Radio>
<Radio value="team">Team</Radio>
</RadioGroup>
<RadioGroup
name="contact"
legend="How should we contact you?"
error="Choose how we should contact you"
>
<Radio value="email">Email</Radio>
<Radio value="phone">Phone</Radio>
</RadioGroup>
| State | Selector | Recommended look |
|---|---|---|
| Hover | :hover, inside @media (hover: hover) | The ring at 80% of the text colour, up from 55% |
| Focus | input:focus-visible | A 2px outline, 2px from the ring, in --color--focus |
| Checked | :has(> input:checked) | The ring filled with --color--primary, and a dot inside it |
| Disabled | :has(> input:disabled) | 50% opacity and a not-allowed cursor |
| Invalid | The group's [aria-invalid="true"] | A 2px ring in --color--error-text, and the error message |
A checked radio is filled as well as dotted, so it differs from the others by its shape, and not by its colour alone (WCAG 1.4.1). A radio inside a disabled group matches :disabled too, since a disabled <fieldset> disables every form control inside it, so one selector covers both. We recommend leaving :invalid out of it, for the same reason as on the Input: it matches a required group before anyone has had a chance to answer it.
None of the states change a radio's size or position. The invalid ring's second pixel is drawn inside the indicator, and the focus ring outside it. You'll find how each state behaves under Every state.
Layout
The Radio Group stacks its radios, one per line, each as wide as its label, and we recommend keeping them that way. In a column, every option starts at the same edge, and a long label can wrap without pushing the next option onto a line of its own.
The one exception is a question with only two options, both of them short, like "Yes" and "No". The GOV.UK Design System lets those sit side by side, and so do we, with orientation="horizontal". On screens narrower than 640px, like phones, they stack again, as GOV.UK's do. The group doesn't set aria-orientation, since a horizontal group stacks again on narrow screens, which a fixed attribute can't follow, and the arrow keys move through the radios both ways whichever way they're laid out.
<RadioGroup
name="call"
legend="Can we call you about your order?"
hint="We only call if something's wrong with it"
orientation="horizontal"
>
<Radio value="yes">Yes</Radio>
<Radio value="no">No</Radio>
</RadioGroup>
The radios sit 24px apart, so each label reads as its own radio's rather than the next one's. We recommend keeping a row to two short options, as GOV.UK does. With a third option, or a label that wraps, it's harder to see which circle goes with which words.
An option sometimes needs more than its label, like "Other", which needs an input to say what it is. Put the Input beside its radio, inside the group, with the same size, and the two line up:
<RadioGroup
name="source"
legend="How did you hear about us?"
value={source}
onChange={(event) => setSource(event.target.value)}
>
<Radio value="friend">From a friend</Radio>
<Radio value="search">From a search engine</Radio>
<div className={styles.other}>
<Radio value="other">Other</Radio>
<Input
name="source-detail"
aria-label="Other, how you heard about us"
onChange={() => setSource("other")}
/>
</div>
</RadioGroup>
.other is a flex row of yours, like display: flex; align-items: center; gap: 0.75rem, with the Input set to fill what's left. Here's what to keep in mind:
- Typing in the input checks "Other", so people don't have to do both. Checking it when the input only gets focus would change the answer of anyone who tabs past it.
- The input's name includes the option's, "Other, how you heard about us", so screen readers don't announce an input called "Other" with no question.
- Keep the input in the page whether "Other" is checked or not, so it stays in the same place in the tab order, which is right after the group.
Tokens
The radio group reads five theme tokens, which every component shares:
| Token | Used for |
|---|---|
--color--neutral-text | The legend and labels, and mixed into the rings and the hint |
--color--primary | The fill of a checked radio |
--color--primary-contrast | The dot on top of it |
--color--error-text | The rings and the error message of an invalid group |
--color--focus | The focus ring, which is the same for every component |
The ring is the text colour at 55%, which needs 3:1 against the page, since an empty radio is nothing but its ring, and 80% on hover. The hint is the text colour at 70%, which needs 4.5:1. With our reference tokens, from the Tailwind CSS palette, the ring comes to 3.5:1 and the hint to 5.6:1 on white, and 5.0:1 and 7.4:1 on zinc-900, as with the Input.
The checked fill comes to 6.4:1 against white and 5.7:1 against zinc-900, and the dot to 6.4:1 against the fill in both. If you change the tokens, make sure to check the same pairs. You'll find the tokens themselves in the Button's tokens.css, which you can copy into your theme.
If you need to tune a radio group, you can set its own variables, such as --radio-group--gap, from a class on the group, and each Radio's, such as --radio--indicator--size, from a class on the Radio. Their styles sit in the components cascade layer, so your class wins without any specificity tricks:
import styles from "./Survey.module.css";
export function Survey() {
return (
<RadioGroup name="rating" legend="How was your visit?" className={styles.roomy}>
<Radio value="good">Good</Radio>
<Radio value="poor">Poor</Radio>
</RadioGroup>
);
}
.roomy {
--radio-group--gap: 0.75rem;
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
radio-group/contrast | Must | Component | The legend, labels, hint and error message have a contrast of 4.5:1, and the focus ring has 3:1 against the page | WCAG 1.4.3, 1.4.11 (AA) | axe color-contrast, contrast checker |
radio-group/indicator-contrast | Must | Component | The ring of an unchecked radio and the fill of a checked one have a contrast of 3:1 against the page | WCAG 1.4.11 (AA) | Contrast checker |
radio-group/checked-shape | Must | Component | A checked radio differs from an unchecked one by its shape, like a dot, and not by its colour alone | WCAG 1.4.1 (A) | Review |
radio-group/focus-visible | Must | Component | Focus shows an outline at least 2px thick around the indicator, outside it | WCAG 2.4.7 (AA), 2.4.13 (AAA) | Keyboard |
radio-group/target-size | Must | Component | Each radio's label is part of its target, which is at least 24 × 24px, and each row is at least 44px tall on touch screens | WCAG 2.5.8 (AA), 2.5.5 (AAA) | Unit test, emulation |
radio-group/input-heights | Should | Component | Each row is as tall as an Input of the same size, so a radio lines up with an input beside it | Open Components | Visual regression test |
radio-group/resizable | Must | Component | Rows are sized with min-height in rem, so they grow with their text, and long labels wrap beside the indicator | Open Components, beyond WCAG 1.4.4, 1.4.12 (AA) | 200% zoom |
radio-group/stable-size | Must | Component | No state changes a radio's size or position | Open Components | Visual regression test |
radio-group/state-selectors | Should | Component | States are styled from the attributes that expose them, on the <input> or the group, and never from :invalid | Open Components | Review |
radio-group/hover-capable | Should | Component | Hover styles only apply inside @media (hover: hover) | Open Components | Review |
radio-group/forced-colors | Must | Component | Every radio, its checked state and focus stay visible in forced colours mode | Open Components | Emulation |
radio-group/more-contrast | Should | Component | The rings are drawn at full strength when prefers-contrast: more is set | Open Components | Emulation |
radio-group/stacked | Should | Usage | Radios are stacked one per line, in an order people can predict, apart from two short options, which may sit side by side | Open Components | Review |
Overview
How to build a radio group that asks one clear question, lets people pick one answer with a pointer or the arrow keys, says what's wrong when they skip it, and that agents can check.
User Experience
How a radio group behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.