User Experience (UX)
Now let's look at how a radio group behaves for every person, input and setting. A good radio group is accessible, predictable, designed in every state and adaptive.
Accessible
Use native radios
A native <input type="radio"> gives you the radio role, its checked state, a single Tab stop for the whole group, the arrow keys, a label you can press, form submission and required, all for free. An element with role="radio" only gives you the role. You'd have to rebuild the rest by hand, from moving tabindex around as people press the arrow keys to a hidden input that sends the value, and every one of those is one more thing to get wrong.
{/* Do: you get the role, the keyboard, the label and form submission for free */}
<label>
<input type="radio" name="delivery" value="standard" />
Standard
</label>
{/* Don't: there's no keyboard, label or form submission until you rebuild them */}
<div role="radio" aria-checked={checked} tabIndex={0} onClick={check}>
Standard
</div>
Name the group with its legend
The legend is the group's accessible name, and each label is a radio's. Screen readers read the legend as focus enters the group, along with the label of the radio that has focus, so people hear the question and the answer together. Voice control users say a label out loud ("click Express"), and tests and agents use both to find the group and its radios.
<RadioGroup
name="delivery"
legend="How would you like it delivered?"
hint="Orders placed before 2pm leave the same day"
>
<Radio value="standard">Standard, in 3 to 5 working days</Radio>
<Radio value="express">Express, the next working day</Radio>
<Radio value="pickup">Collect from a store</Radio>
</RadioGroup>
- Every group has a
<fieldset>and a<legend>that asks the question its radios answer (WCAG 1.3.1, 3.3.2). A heading or a paragraph above the radios looks the same, but screen readers don't read it when focus enters the group, so people hear "Express, radio button" with no question at all. - The group points to its legend with
aria-labelledby, as well. A fieldset takes its name from its legend on its own, but the group has theradiogrouprole, as under Invalid, andaria-labelledbysays so outright, so the name doesn't depend on each browser's fieldset naming. The legend stays visible, so the name people hear is the question they see. We recommend againstaria-labelin its place, which sighted people never see. - Keep the legend short, since some screen readers read it again with every radio. Anything longer goes in the hint.
- A hint that applies to every option goes in the group's description, which screen readers read after its name. The Radio Group links it with
aria-describedbyfor you, from itshint. - Every radio has a visible label. The label wraps its input, so it names the radio without an
id, and pressing anywhere on it checks the radio, which makes for a much bigger target than the indicator. - Every radio in a group shares the group's
name, and every group on the page has a name of its own. Browsers make a group out of the radios that share a name in the same form, so two groups with one name become one, and radios with different names let people check more than one.
Contrast and size
- The legend, the labels, the hint and the error message need a contrast of at least 4.5:1 with their background, since people need to read them (WCAG 1.4.3). Disabled radios are exempt, but they should still be readable.
- The ring of an unchecked radio, and the fill of a checked one, need 3:1 against the page (WCAG 1.4.11). An unchecked radio is nothing but its ring, so it's how people see that the option is there at all. The focus ring needs 3:1 as well.
- Every radio is at least 24 × 24px with its label (WCAG 2.5.8). On touch screens, each row grows to 44px tall (WCAG 2.5.5).
Show focus
When a radio has focus, its indicator shows an outline in the same focus colour as every other component (WCAG 2.4.7). Like the Button's, it's at least 2px thick and drawn outside the ring, and we recommend 2px for both. Browsers only match :focus-visible on a radio when people use the keyboard, so a click checks the radio without a ring, while an arrow key shows people where they are.
You'll also want to keep focused radios clear of sticky headers and footers, which you can do with scroll-padding (WCAG 2.4.11, 2.4.12).
Predictable
Keyboard
| Key | Radio Group |
|---|---|
Tab | Moves focus into the group, onto the checked radio, or the first one when none is checked, and on out of it |
Shift+Tab | Moves focus back into the group, onto the checked radio, or in Chrome the last one when none is checked, and back out of it |
ArrowDown, ArrowRight | Moves focus to the next radio and checks it, skipping disabled ones, from the last back to the first |
ArrowUp, ArrowLeft | Moves focus to the previous radio and checks it, from the first back to the last |
Space | Checks the radio with focus, when none is checked yet |
That's all the browser's doing, from the radios' shared name, and it's the keyboard the APG's Radio Group pattern asks for (WCAG 2.1.1), apart from one case. With nothing checked, the APG puts focus on the first radio however people tab in, while Chrome puts it on the last one with Shift+Tab, which the APG notes some browsers do. Since it's the browser's choice, we leave it be. A group is one stop in the tab order, so people can tab past a long list of options in one go, and the arrow keys check as they move, so there's always one answer once people have started.
Don't act on change
Checking a radio never submits its form, opens another page or moves focus (WCAG 3.2.2). Keyboard users check every option between the one they're on and the one they want, so a group that acts on each one sends them somewhere they never meant to go. Wait for the form's submit button, and if an answer brings up a follow-up question, put it right after the group, where people will reach it next.
Choose a default with care
Once people have checked a radio, they can't go back to having none checked, so a default is an answer they never gave unless they notice it. We recommend checking one by default only when it's safe and most people would pick it anyway, like "Standard" delivery. To check it, start the group's bound value on it, or give its Radio checked, which hands the group that value when it has none yet.
Otherwise, leave all of them unchecked and make the group required, so people read the question and answer it. If "none of these" is a real answer, give it a radio of its own.
Write options people can tell apart
- Keep labels short, and start each one with the words that set it apart, as in "Express, the next working day".
- Make sure the options don't overlap and cover every answer, adding one like "Other" or "I'm not sure" when they don't.
- Put them in an order people can predict, like cheapest first, or the most common first.
- Keep it to a few options that fit on the screen. When there are many, like countries, a Select fits better (see FAQ).
Every state
Checked
One radio in a group is checked at a time, and checking another unchecks it. Its form sends the checked radio's value under the group's name, and nothing at all while none is checked. A checked radio is filled and dotted, so it's clear at a glance, in colour or not.
Disabled
You can disable one radio, or the whole group. A disabled radio can't be focused or checked, the arrow keys skip it, screen readers announce it as dimmed or unavailable, and its form leaves it out. A disabled group sets disabled on its <fieldset>, which disables every radio inside it in one go, so its value is left out of the form too.
We recommend disabling as little as you can. A disabled option can't explain why it's unavailable, so say why in its label, as in "Next day (not available for this address)", or leave it out. Never disable a radio while it has focus, either, since the browser takes the focus away, and keyboard and screen reader users lose their place.
Invalid
An invalid group needs an answer, or a different one, and an error message that says what to do. The Radio Group shows the state while its error is set, and the page decides when to set it, since only the page knows what makes an answer right.
Its look has to match what screen readers announce, and ARIA only supports aria-invalid on some roles. radiogroup is one of them, while group, the role a <fieldset> has, and radio aren't (WAI-ARIA 1.2). So the Radio Group gives its <fieldset> the radiogroup role, and sets aria-invalid="true" on it while its error shows.
The fieldset still takes its name from its <legend>, since the HTML Accessibility API Mappings name a fieldset from its legend whatever its role, and aria-labelledby points to the legend too, so the name doesn't depend on each browser's fieldset naming. The red rings read the same attribute, so they show exactly when screen readers say the group is invalid.
import { useRef, useState, type FormEvent } from "react";
import { flushSync } from "react-dom";
export function Checkout() {
const [delivery, setDelivery] = useState("");
const [error, setError] = useState("");
const [status, setStatus] = useState("");
const first = useRef<HTMLInputElement>(null);
function answer(value: string) {
setDelivery(value);
// An answer fixes the error, so it clears as soon as there is one.
setError("");
}
async function order(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!delivery) {
// Focus once the error is in the page, so screen readers read it with the group.
flushSync(() => {
setError("Choose how you'd like your order delivered");
setStatus("Couldn't place your order. Check the delivery options.");
});
first.current?.focus();
return;
}
await api.order(delivery);
setStatus("Order placed");
}
return (
<form noValidate onSubmit={order}>
<RadioGroup
name="delivery"
legend="How would you like it delivered?"
required
error={error}
value={delivery}
onChange={(event) => answer(event.target.value)}
>
<Radio ref={first} value="standard">Standard</Radio>
<Radio value="express">Express</Radio>
</RadioGroup>
<Button type="submit" color="primary">Place order</Button>
<p role="status">{status}</p>
</form>
);
}
- Say what to do, as in "Choose how you'd like your order delivered", rather than "This field is required" (WCAG 3.3.3). The Radio Group puts the message above the radios and links it to the group with
aria-describedby, so screen readers announce the group as invalid and read the message after its legend (WCAG 3.3.1). The red rings alone can't say any of that, and they mean nothing to people who can't tell red apart (WCAG 1.4.1). - Check a group when people submit, and not when they pass through it. Someone who tabs past a question hasn't answered it yet, and may well come back. Once an error shows, clear it as soon as they check a radio, since any answer fixes a missing one.
- When a submit fails, move focus to the first radio of the first invalid group, so screen readers read its legend, its error and the radio together. Do it once the error is in the page, which in most frameworks means waiting for the next render, as above. With several errors, a summary at the top of the form, with a link to each group, works even better.
- Put
novalidateon the form, so that the browser's own error bubbles don't compete with yours, and keeprequiredon the group, which tells screen readers that it's required.
Adaptive
Pointer, touch and pen
- Hover styles only apply to pointers that can actually hover (
@media (hover: hover)), otherwise a tap would leave the ring looking hovered. - On touch screens, each row is at least 44px tall, so a finger lands on the option it meant to, without the radios looking any bigger.
- The whole label checks its radio, but each row is only as wide as its label, so pressing the empty space beside an option doesn't check it by surprise.
More contrast
When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the rings are drawn in the text colour at full strength, rather than at 55%. The labels and the hint already pass 4.5:1, so they stay as they are.
Forced colours
Windows contrast themes replace every colour with the user's own, and drop backgrounds and shadows, which is what draws the dot. So in these themes, the Radio Group lets the browser draw its own radios, which follow the user's colours, checked state and all. Focus is drawn with outline, and a disabled radio's label is marked with GrayText. The invalid ring is a colour and a shadow, which forced colours replace, so it's the error message, in text, that tells people something's wrong, which is one more reason to always show one.
Colour scheme
The tokens use light-dark(), which follows color-scheme, so the Radio Group adapts to your light and dark themes on its own.
Text size and spacing
Rows have a minimum height in rem, and the indicator is sized in rem too, so they grow with their text instead of clipping it, at 200% text size (WCAG 1.4.4) and with wider line, letter and word spacing (WCAG 1.4.12). A long label wraps beside its indicator rather than under it, so the options stay easy to tell apart.
Languages and direction
The Radio Group uses logical properties, so in right-to-left languages its indicators sit on the right, before their labels, without any changes. Remember to translate the legend, the labels, the hint and the error message along with the rest of the page, and to mark any that are in another language with lang, so that screen readers pronounce them correctly (WCAG 3.1.2).
UX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
radio-group/native-radios | Must | Component | Each radio is a native <input type="radio">, and never an element with role="radio" | Open Components, beyond WCAG 2.1.1, 4.1.2 (A) | Unit test |
radio-group/fieldset-legend | Must | Component | The radios sit in a <fieldset> that its <legend> names | WCAG 1.3.1, 3.3.2 (A) | Unit test |
radio-group/question-legend | Should | Usage | The legend asks the question the radios answer, briefly, with anything longer in the hint | Open Components | Review |
radio-group/labelled-radios | Must | Both | Every radio has a visible label, which names it and checks it when pressed | WCAG 1.3.1, 3.3.2, 4.1.2 (A) | Unit test, axe label |
radio-group/shared-name | Must | Both | Every radio in a group shares its name, and every group on the page has a name of its own | HTML | Unit test |
radio-group/described | Should | Both | A hint for the whole group is linked to it with aria-describedby | WCAG 1.3.1 (A) | Unit test |
radio-group/keyboard | Must | Component | Tab stops once per group, on the checked radio or the first one, the arrow keys move focus and check, wrapping round, and Space checks | APG, WCAG 2.1.1 (A) | Unit test, keyboard |
radio-group/skip-disabled | Must | Component | The arrow keys skip a disabled radio, which can't be checked, and its form leaves it out | HTML | Unit test |
radio-group/no-change-on-input | Must | Usage | Checking a radio never submits its form, opens another page or moves focus | WCAG 3.2.2 (A) | Keyboard |
radio-group/careful-default | Should | Usage | A radio is only checked by default when that answer is safe and most people pick it, and otherwise the group is required | Open Components | Review |
radio-group/distinct-options | Should | Usage | Options are short, don't overlap and cover every answer, with one like "Other" when they don't | Open Components | Review |
radio-group/error-message | Must | Usage | An invalid group has an error message, in text, that says what to do | WCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA) | Screen reader |
radio-group/invalid-state | Must | Component | While it has an error message, the group has role="radiogroup" and aria-invalid="true", is described by the message and only looks invalid then | WCAG 4.1.2 (A), WAI-ARIA | Unit test |
radio-group/validate-on-submit | Should | Usage | Errors show when people submit, never as they pass through the group, and clear as soon as they check a radio | Open Components | Review |
radio-group/focus-first-error | Should | Usage | When a submit fails, focus moves to the first radio of the first invalid group, or to a summary of the errors, once the error messages are in the page | Open Components | Keyboard, screen reader |
radio-group/disabled-inert | Must | Component | A disabled group disables every radio, which can't be focused, checked or submitted | HTML | Unit test |
radio-group/required | Should | Component | A required group makes every radio required, so its form can't be sent without an answer | HTML | Unit test |
radio-group/keep-focus | Must | Usage | A radio is never disabled while it has focus | Open Components | Keyboard |
radio-group/focus-not-obscured | Must | Usage | Sticky headers and footers never cover a focused radio, for example thanks to scroll-padding | WCAG 2.4.11 (AA), 2.4.12 (AAA) | Keyboard |
radio-group/mirrors-in-rtl | Should | Component | It uses logical properties, so each indicator sits before its label in either direction | Open Components | Review |
FAQ
Radio Group or something else?
A Radio Group asks for one answer out of a few, and shows them all. For anything else, another component will do a better job:
| When people need to… | Use |
|---|---|
| Pick one option out of a few that fit on the screen | Radio Group |
| Pick one option out of many, like a country | Select |
| Pick any number of options, from none to all of them | Checkbox, in a group |
| Agree to one thing, like the terms | Checkbox, on its own |
| Turn a setting on or off, with an effect they see right away | Switch |
| Answer yes or no to a question in a form, which is sent later | Radio Group, with Yes and No |
A Select hides its options until it's opened, which saves room, but people have to open it to compare them, so we recommend radios whenever the options fit. A Switch acts the moment it's flipped, while a radio's answer waits for its form to be sent. A Checkbox lets people pick more than one, or none, which a radio group can't do once it has an answer.
The Checkbox, the Checkbox Group and the Switch use the same size names, and show they're invalid with aria-invalid and an error message, as the Radio Group does. The Select, on our Roadmap, will too.