Radio Group

User Interface (UI)

How a radio group looks, from its parts and sizes to its states, layout and tokens.

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

  1. Group — The <fieldset>, which groups the radios and carries the size.
  2. Legend — The question the radios answer, which names the group.
  3. Hint — Optional. Help that applies to every option, which describes the group.
  4. Radio — One option, its indicator and its label, which people can press anywhere.
  5. Indicator — The native radio, drawn as a ring, which fills with a dot once it's checked.
  6. 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.

Delivery (xs)
Delivery (sm)
Delivery (md)
Delivery (lg)
Delivery (xl)
<RadioGroup size="xs" name="delivery" legend="Delivery">
  <Radio value="standard">Standard</Radio>
  <Radio value="express">Express</Radio>
</RadioGroup>
{/* …sm, md, lg, xl */}
SizeRow min. heightTextIndicatorGap
xs24px12px14px4px
sm32px14px16px8px
md36px14px16px8px
lg40px16px20px8px
xl48px16px20px8px

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.

Delivery
At rest, with one checked (try hovering, clicking or tabbing to it)
Shipping
One radio disabled
Plan
Group disabled
How should we contact you?

Choose how we should contact you

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>
StateSelectorRecommended look
Hover:hover, inside @media (hover: hover)The ring at 80% of the text colour, up from 55%
Focusinput:focus-visibleA 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
InvalidThe 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.

Can we call you about your order?

We only call if something's wrong with it

<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:

How did you hear about us?
<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:

TokenUsed for
--color--neutral-textThe legend and labels, and mixed into the rings and the hint
--color--primaryThe fill of a checked radio
--color--primary-contrastThe dot on top of it
--color--error-textThe rings and the error message of an invalid group
--color--focusThe 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:

Survey.tsx
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>
  );
}
Survey.module.css
.roomy {
  --radio-group--gap: 0.75rem;
}

UI Checklist

RuleLevelScopeRequirementBasisCheck
radio-group/contrastMustComponentThe legend, labels, hint and error message have a contrast of 4.5:1, and the focus ring has 3:1 against the pageWCAG 1.4.3, 1.4.11 (AA)axe color-contrast, contrast checker
radio-group/indicator-contrastMustComponentThe ring of an unchecked radio and the fill of a checked one have a contrast of 3:1 against the pageWCAG 1.4.11 (AA)Contrast checker
radio-group/checked-shapeMustComponentA checked radio differs from an unchecked one by its shape, like a dot, and not by its colour aloneWCAG 1.4.1 (A)Review
radio-group/focus-visibleMustComponentFocus shows an outline at least 2px thick around the indicator, outside itWCAG 2.4.7 (AA), 2.4.13 (AAA)Keyboard
radio-group/target-sizeMustComponentEach radio's label is part of its target, which is at least 24 × 24px, and each row is at least 44px tall on touch screensWCAG 2.5.8 (AA), 2.5.5 (AAA)Unit test, emulation
radio-group/input-heightsShouldComponentEach row is as tall as an Input of the same size, so a radio lines up with an input beside itOpen ComponentsVisual regression test
radio-group/resizableMustComponentRows are sized with min-height in rem, so they grow with their text, and long labels wrap beside the indicatorOpen Components, beyond WCAG 1.4.4, 1.4.12 (AA)200% zoom
radio-group/stable-sizeMustComponentNo state changes a radio's size or positionOpen ComponentsVisual regression test
radio-group/state-selectorsShouldComponentStates are styled from the attributes that expose them, on the <input> or the group, and never from :invalidOpen ComponentsReview
radio-group/hover-capableShouldComponentHover styles only apply inside @media (hover: hover)Open ComponentsReview
radio-group/forced-colorsMustComponentEvery radio, its checked state and focus stay visible in forced colours modeOpen ComponentsEmulation
radio-group/more-contrastShouldComponentThe rings are drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation
radio-group/stackedShouldUsageRadios are stacked one per line, in an order people can predict, apart from two short options, which may sit side by sideOpen ComponentsReview
Copyright © 2026