Components

Radio Group

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.

Radio groups let people pick one option out of a few, like a delivery speed or a plan, while every option stays in view. They look like a list of circles with words beside them, which is why they're so often built from scratch. Think of radios that each have their own name, so people can check all of them at once, a question that's a heading rather than a legend, so screen readers never read it, or a group that turns red when people skip it, but doesn't tell screen reader users anything at all.

This page walks you through building one the right way. We'll look at:

  • User Interface (UI) - how a radio group looks
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to click and arrow through them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Radio Group with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Radio Group is the question, and each Radio inside it is one answer. The group renders its legend, its hint and its error message, since a fieldset needs its legend inside it, and its radios share its name, its size and its value, so they can never disagree.

Delivery

Express and next day cost extra

<RadioGroup
  name="delivery"
  legend="Delivery"
  hint="Express and next day cost extra"
  value={delivery}
  onChange={(event) => setDelivery(event.target.value)}
>
  <Radio value="standard">Standard</Radio>
  <Radio value="express">Express</Radio>
  <Radio value="next-day">Next day</Radio>
</RadioGroup>

At a glance

AspectRadio Group
Element<fieldset> with a <legend>, around native <input type="radio">s, each inside its <label>
Roleradiogroup on the fieldset, and radio on each input
NameThe group's <legend>, through aria-labelledby, and each radio's <label>
KeyboardTab stops once per group, the arrow keys move and check, wrapping round, and Space checks
StatesHover, focus, checked, disabled and invalid
Sizesxs, sm, md, lg and xl, with the same heights as the Input
PatternRadio Group in the APG, which native radios follow
WCAG 2.21.3.1, 1.4.1, 1.4.3, 1.4.11, 2.1.1, 2.4.7, 2.5.8, 3.2.2, 3.3.1, 3.3.2 and 4.1.2, among others

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

  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;
}

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.

How would you like it delivered?

Orders placed before 2pm leave the same day

<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 the radiogroup role, as under Invalid, and aria-labelledby says 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 against aria-label in 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-describedby for you, from its hint.
  • 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

KeyRadio Group
TabMoves focus into the group, onto the checked radio, or the first one when none is checked, and on out of it
Shift+TabMoves 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, ArrowRightMoves focus to the next radio and checks it, skipping disabled ones, from the last back to the first
ArrowUp, ArrowLeftMoves focus to the previous radio and checks it, from the first back to the last
SpaceChecks 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.

How would you like it delivered?

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 novalidate on the form, so that the browser's own error bubbles don't compete with yours, and keep required on 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).

Developer Experience (DX)

Next, let's look at how developers use the Radio Group. It has one API, with the same names as every other component, it's typed end to end, and it works with your forms however you build them.

import { useState } from "react";
import { Radio, RadioGroup } from "@/components/RadioGroup";

export function Delivery() {
  const [delivery, setDelivery] = useState("standard");

  return (
    <RadioGroup
      name="delivery"
      legend="Delivery"
      value={delivery}
      onChange={(event) => setDelivery(event.target.value)}
    >
      <Radio value="standard">Standard</Radio>
      <Radio value="express">Express</Radio>
    </RadioGroup>
  );
}

Consistent

Once you've learned the Input's API, you already know most of the Radio Group's. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
sizePropxs, sm, md, lg or xl, with the same heights everywhere
disabledPropThe component can't be used, here the whole group or one radio
requiredPropIts form can't be sent until it has a value
namePropThe name its form sends its value under
valuePropThe value, which you bind the way your framework binds a form control's

We recommend size="md" as the default. The Radio Group also leaves a few props out, on purpose:

  • There's no variant or color. A radio has one checked look, from the primary colour, and its one other coloured state, invalid, comes from aria-invalid, so its colour and its state can't disagree.
  • There's no options prop. The radios are children, so each one can have its own label, its own disabled and its own attributes, and the code reads like the form it renders.
  • The Radio has no label prop either. Its content is its label, which also keeps it inside the <label> that checks it.

Type-safe

Props, events and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like a group without a legend, which every group needs.

Radio Group propTypeDefaultDescription
namestringRequired. Every radio submits under it, and the legend's, hint's and error's IDs start with it
legendstringRequired. The question, which names the group
hintstringHelp that applies to every option, which describes the group
errorstringWhat's wrong and how to fix it. While it's set, the group is invalid
size"xs" | "sm" | "md" | "lg" | "xl""md"How big the radios are
orientation"vertical" | "horizontal""vertical"Stacks the radios, or sets them side by side, for two short options
disabledbooleanfalseDisables every radio, and leaves the group out of its form
requiredbooleanfalseMakes every radio required, so its form can't be sent without an answer
valuestringThe checked radio's value, when you bind it. Without it, the group keeps its own
Radio propTypeDefaultDescription
valuestringRequired. What the group's value is, and its form sends, while this radio is checked
disabledbooleanfalseTakes this one option away, while the others stay
checkedbooleanfalseStarts the group on this radio, by handing it its value when it has none yet, on the server too. Give it to one radio per group
EventPayloadDescription
changeEventFires when people check a radio, from its <input>, and bubbles up to the group

A binding to value listens to it for you. The browser doesn't fire change on the radio that gets unchecked, so listen on the group, or bind its value, rather than listening to each radio.

SlotDescription
Radio Group defaultThe radios, one Radio for each option
Radio defaultThe label, which names the radio and checks it when pressed

We also export RadioGroupProps and RadioProps, which come in handy for wrappers and data-driven forms:

import type { RadioGroupProps, RadioProps } from "@/components/RadioGroup";

const question: RadioGroupProps & { options: (RadioProps & { label: string })[] } = {
  name: "delivery",
  legend: "How would you like it delivered?",
  options: [
    { value: "standard", label: "Standard" },
    { value: "express", label: "Express" },
  ],
};

Composable

The Radio Group renders the <fieldset>, so everything you pass it that isn't a prop lands there, like a class, an id or a listener. A Radio renders two elements, so it splits what you pass between them, as the Input does. Classes and styles go on its root, the <label>, so a class can set its variables. Everything else lands on the <input>, including ARIA attributes and event listeners, and a ref to the Radio reaches the <input> too, since that's what focus() needs.

Anything can sit between the radios, like the Input beside "Other" under Layout, as long as every Radio stays inside its group, which is where it gets its name, its size and its value from.

Forms

The radios are real form controls, so the Radio Group works with your forms however you build them. You can bind its value, as above, or leave it to the browser and read it from the form when it's sent, by its name. That also means the form works before your JavaScript loads.

import type { FormEvent } from "react";

export function ChoosePlan() {
  function choose(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    api.choosePlan(data.get("plan"));
  }

  return (
    <form onSubmit={choose}>
      <RadioGroup name="plan" legend="Which plan would you like?" required>
        <Radio value="personal">Personal</Radio>
        <Radio value="team">Team</Radio>
      </RadioGroup>
      <Button type="submit" color="primary">Continue</Button>
    </form>
  );
}

Form libraries work the same way. They register the radios by their shared name, through their refs or their attributes and listeners, which is why each Radio puts them on its <input>.

With other components

  • An Input beside a radio, like "Other", takes the same size, so the two line up.
  • A Button sends the form the group is in. Checking a radio never does, as under Don't act on change.
  • The Field, on our Roadmap, labels a single control, like an Input. A group of radios is labelled by its legend instead, which is why the Radio Group renders its own legend, hint and error message, and why they look the way the Field's will.
  • 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. You'll find when to use each under FAQ.

Controllable

The Radio Group doesn't keep any state you can't reach. The value is yours to bind, or to leave to the browser, and every other state is a prop that you control:

StateHow you control it
Valuevalue, bound to your state, or left to the browser, and started by a Radio's checked
Disableddisabled on the group, or on one Radio
Requiredrequired on the group
Invaliderror, set while there's something to fix (see Invalid)
FocusA ref to a Radio's <input>, and focus()

Agentic Experience (AX)

Finally, let's look at how agents read, fill in and check a radio group. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against.

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names, values and states. This is how they see the examples under States, in Chrome:

- radiogroup "Delivery":
  - text: Delivery
  - radio "Standard" [checked]
  - text: Standard
  - radio "Express"
  - text: Express
- radiogroup "Shipping":
  - text: Shipping
  - radio "Standard" [checked]
  - text: Standard
  - radio "Collect from a store" [disabled]
  - text: Collect from a store
- radiogroup "Plan":
  - text: Plan
  - radio "Personal" [disabled]
  - text: Personal
  - radio "Team" [checked] [disabled]
  - text: Team
- radiogroup "How should we contact you?" [invalid]:
  - text: How should we contact you?
  - paragraph: Choose how we should contact you
  - radio "Email"
  - text: Email
  - radio "Phone"
  - text: Phone

The legend names the group, through aria-labelledby, and the group carries [invalid], since it has the radiogroup role. A group without a legend is a radio group with no name, so an agent can't tell the delivery options from the payment options, any more than a screen reader user can. With a role and a name, an agent can find the group, and the radio inside it, and check it:

await page.getByRole("radiogroup", { name: "Delivery" }).getByRole("radio", { name: "Express" }).check();

Described

Here's the whole contract in one block, for agents to read before they build or review a radio group:

component: RadioGroup
summary: Asks one question with a few answers that all fit on the screen, and lets people pick one. Its legend names it, and each Radio inside it is an answer.
standard: https://opencomponents.dev/docs/components/radio-group
element: fieldset # with role=radiogroup, around one Radio for each option
role: radiogroup # each radio is an <input type="radio">, with the radio role
props:
  name: { type: string } # required. Every radio submits under it, and the legend's, hint's and error's IDs start with it
  legend: { type: string } # required. The question, which names the group
  hint: { type: string }
  error: { type: string } # while it's set, the group is invalid
  size: { type: [xs, sm, md, lg, xl], default: md }
  orientation: { type: [vertical, horizontal], default: vertical } # horizontal only for two short options, and never with aria-orientation
  disabled: { type: boolean, default: false }
  required: { type: boolean, default: false }
  value: { type: string } # the checked radio's value, when it's bound. Without it, the group keeps its own
slots:
  default: The radios, one Radio for each option.
events:
  change: Event, from the radio people checked. It bubbles up to the group.
states:
  hover: ":hover on a radio, in @media (hover: hover)"
  focus: "input:focus-visible"
  checked: ":has(> input:checked) on a radio"
  disabled: ":has(> input:disabled) on a radio, which every radio in a disabled group matches"
  invalid: "[aria-invalid=true] on the group, never :invalid"
keyboard:
  Tab: Moves focus into the group, onto the checked radio or the first one, and out of it again.
  ArrowDown: Moves focus to the next radio and checks it, skipping disabled ones. Wraps from the last to the first. ArrowRight too.
  ArrowUp: Moves focus to the previous radio and checks it. Wraps from the first to the last. ArrowLeft too.
  Space: Checks the radio with focus.
parts: [legend, hint, error, options] # data-slot, in this order. The options hold one radio for each option. Each radio is a Radio, a <label> with the parts [control, label], the props value, disabled and checked, and the variables --radio--height, --radio--gap, --radio--font-size, --radio--indicator--size, --radio--indicator--border-color, --radio--indicator--fill and --radio--dot--color
tokens:
  theme: ["--color--neutral-text", "--color--primary", "--color--primary-contrast", "--color--error-text", "--color--focus"]
  variables: [--radio-group--gap, --radio-group--font-size, --radio-group--options--column-gap]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/radio-group#checklist

It's also published on its own at /raw/docs/components/radio-group.yaml, with every rule from the checklist in place of the rules link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like radio-group/fieldset-legend, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's a required group with a hint, an error and an answer:

<RadioGroup
  name="delivery"
  legend="Delivery"
  hint="Express arrives the next working day"
  error="Choose how you'd like your order delivered"
  required
  value="express"
>
  <Radio value="standard">Standard</Radio>
  <Radio value="express">Express</Radio>
</RadioGroup>

And here's what it renders:

<fieldset
  class="radio-group"
  role="radiogroup"
  data-size="md"
  data-orientation="vertical"
  aria-labelledby="delivery-legend"
  aria-invalid="true"
  aria-describedby="delivery-hint delivery-error"
>
  <legend id="delivery-legend" data-slot="legend">Delivery</legend>
  <p id="delivery-hint" data-slot="hint">Express arrives the next working day</p>
  <p id="delivery-error" data-slot="error">Choose how you'd like your order delivered</p>
  <div data-slot="options">
    <label class="radio" data-slot="radio" data-size="md">
      <input data-slot="control" type="radio" name="delivery" value="standard" required />
      <span data-slot="label">Standard</span>
    </label>
    <label class="radio" data-slot="radio" data-size="md">
      <input data-slot="control" type="radio" name="delivery" value="express" checked required />
      <span data-slot="label">Express</span>
    </label>
  </div>
</fieldset>
  • The size is mirrored as data-size on the group and on every radio, and the orientation as data-orientation on the group, and both are always there. The role is always radiogroup, so an agent finds every group the same way.
  • Parts are marked with data-slot and always come in the same order: legend, hint, error and options, which holds the radios, each with its control and label. Only the legend, the options and the radios are always there.
  • The legend's, hint's and error's IDs are the group's name with -legend, -hint and -error, so the markup is the same on the server, on the client and in every snapshot. There's nothing random or generated, like IDs or timestamps.
  • States change attributes, and the error message comes and goes, but the radios are never replaced, so focus, the checked radio and the screen reader's position all survive every state change, an error included.

Verifiable

Every rule in the checklist can be checked, and about half of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in RadioGroup.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them.

They run on jsdom, which proves the markup and the behaviour the browser builds on, but not every browser's own keyboard handling. So once you've built your Radio Group, check it with the keyboard in each browser you support: Tab and Shift+Tab into and out of a group, with and without a checked radio, all four arrow keys, Space and a disabled radio.

In the browser

In the browser, you can run axe to check names and contrast, and answer the form the way an agent would, to check what happens when people skip the question:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("the checkout asks how to deliver, and says so when it's skipped", async ({ page }) => {
  await page.goto("/checkout");

  const delivery = page.getByRole("radiogroup", { name: "How would you like it delivered?" });
  await page.getByRole("button", { name: "Place order" }).click();

  await expect(delivery).toHaveAttribute("aria-invalid", "true");
  await expect(delivery).toHaveAccessibleDescription(/Choose how/);
  await expect(delivery.getByRole("radio", { name: "Standard" })).toBeFocused();

  await page.keyboard.press("ArrowDown");
  await expect(delivery.getByRole("radio", { name: "Express" })).toBeChecked();
  await expect(delivery).not.toHaveAttribute("aria-invalid");

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);
});

Some things still need a human, so try your forms with a screen reader, to hear the legend, the hint and the error as focus enters each group, on a phone, and at 200% zoom, with forced colours and more contrast turned on. You can emulate the last two in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as a group renders without a legend, or a Radio renders outside a group, which are the two ways a radio group loses its question.

Prompts

You can point your agent straight at this page. To build a radio group, you could use:

Build a Radio Group component for our React design system that meets the Open Components
Radio Group standard: https://opencomponents.dev/raw/docs/components/radio-group.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then
check your work against every rule in its checklist with a Component or Both scope, and
cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Radio Group against the Open Components Radio Group contract:
https://opencomponents.dev/raw/docs/components/radio-group.yaml

For each rule with a component or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

And to review how a screen uses its radio groups:

Review how our checkout page uses its radio groups against the Open Components Radio
Group contract: https://opencomponents.dev/raw/docs/components/radio-group.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and the
evidence from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A radio group meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Radio Group itself, so they're the ones to check when you build or review one. Usage rules are met by the forms that use it, like asking a question in the legend, so they're the ones to check when you review a screen. Both rules need the two to work together, like labelling every radio: the Radio wraps its input in its label, as long as you give it one.

The basis says where a rule comes from. About half the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from HTML or the APG. The rest are Open Components rules, which are our own, like checking a group when people submit rather than as they pass through it. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level AAA, like the 44px rows on touch screens, and a rule that asks for more than its criterion says beyond, like using native radios, where WCAG would let you rebuild them by hand.

UI rules

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

UX rules

RuleLevelScopeRequirementBasisCheck
radio-group/native-radiosMustComponentEach 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-legendMustComponentThe radios sit in a <fieldset> that its <legend> namesWCAG 1.3.1, 3.3.2 (A)Unit test
radio-group/question-legendShouldUsageThe legend asks the question the radios answer, briefly, with anything longer in the hintOpen ComponentsReview
radio-group/labelled-radiosMustBothEvery radio has a visible label, which names it and checks it when pressedWCAG 1.3.1, 3.3.2, 4.1.2 (A)Unit test, axe label
radio-group/shared-nameMustBothEvery radio in a group shares its name, and every group on the page has a name of its ownHTMLUnit test
radio-group/describedShouldBothA hint for the whole group is linked to it with aria-describedbyWCAG 1.3.1 (A)Unit test
radio-group/keyboardMustComponentTab stops once per group, on the checked radio or the first one, the arrow keys move focus and check, wrapping round, and Space checksAPG, WCAG 2.1.1 (A)Unit test, keyboard
radio-group/skip-disabledMustComponentThe arrow keys skip a disabled radio, which can't be checked, and its form leaves it outHTMLUnit test
radio-group/no-change-on-inputMustUsageChecking a radio never submits its form, opens another page or moves focusWCAG 3.2.2 (A)Keyboard
radio-group/careful-defaultShouldUsageA radio is only checked by default when that answer is safe and most people pick it, and otherwise the group is requiredOpen ComponentsReview
radio-group/distinct-optionsShouldUsageOptions are short, don't overlap and cover every answer, with one like "Other" when they don'tOpen ComponentsReview
radio-group/error-messageMustUsageAn invalid group has an error message, in text, that says what to doWCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA)Screen reader
radio-group/invalid-stateMustComponentWhile it has an error message, the group has role="radiogroup" and aria-invalid="true", is described by the message and only looks invalid thenWCAG 4.1.2 (A), WAI-ARIAUnit test
radio-group/validate-on-submitShouldUsageErrors show when people submit, never as they pass through the group, and clear as soon as they check a radioOpen ComponentsReview
radio-group/focus-first-errorShouldUsageWhen 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 pageOpen ComponentsKeyboard, screen reader
radio-group/disabled-inertMustComponentA disabled group disables every radio, which can't be focused, checked or submittedHTMLUnit test
radio-group/requiredShouldComponentA required group makes every radio required, so its form can't be sent without an answerHTMLUnit test
radio-group/keep-focusMustUsageA radio is never disabled while it has focusOpen ComponentsKeyboard
radio-group/focus-not-obscuredMustUsageSticky headers and footers never cover a focused radio, for example thanks to scroll-paddingWCAG 2.4.11 (AA), 2.4.12 (AAA)Keyboard
radio-group/mirrors-in-rtlShouldComponentIt uses logical properties, so each indicator sits before its label in either directionOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
radio-group/shared-vocabularyMustComponentIt uses the shared names: size, disabled, required, name and valueOpen ComponentsReview
radio-group/typedMustComponentProps, events and slots are typed, with unions rather than string, and name, legend and each radio's value are requiredOpen ComponentsType check
radio-group/control-attributesMustComponentThe group's attributes land on its <fieldset>, and a radio's attributes, listeners and refs on its <input>, while its classes and styles land on its rootOpen ComponentsUnit test
radio-group/form-controlMustComponentIts form sends the checked radio's value under the group's nameHTMLUnit test
radio-group/controllableShouldComponentIts value can be bound both ways or left to the browser, and every other state is a propOpen ComponentsUnit test

AX rules

RuleLevelScopeRequirementBasisCheck
radio-group/role-and-nameMustComponentIt can be found by its role and legend alone, with getByRole("radiogroup", { name }), and each radio with getByRole("radio", { name })WCAG 4.1.2 (A)Unit test
radio-group/deterministic-domMustComponentThe same props render the same markup, with no generated IDs, and states never replace the radiosOpen ComponentsUnit test
radio-group/data-attributesShouldComponentThe size is mirrored as data-size on the group and every radio, the orientation as data-orientation on the group, and parts are marked with data-slotOpen ComponentsUnit test
radio-group/dev-warningsShouldComponentIt warns during development when a group has no legend, or a radio is outside a groupOpen ComponentsUnit test

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. It's two components, the Radio Group and the Radio, which reads its name, size, required state and value from the group it's in. Feel free to copy them into your design system and change how they look, but try to keep how they behave. They read the same theme tokens as the Button, so copy tokens.css from the Button's reference implementation into your theme too.

The tests are in RadioGroup.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script lang="ts">
import type { InjectionKey } from "vue";

export interface RadioGroupProps {
  /** The name every radio submits its value under. The legend's, hint's and error's IDs start with it, so give each group on a page its own. */
  name: string;
  /** Names the group, as its <legend>. Ask the question the radios answer, like "How would you like it delivered?" */
  legend: string;
  /** Help that applies to every option, which screen readers read with the group's name. */
  hint?: string;
  /** What's wrong and how to fix it. While it's set, the group is invalid, and screen readers say so. */
  error?: string;
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  /** Stacks the radios, one per line, or sets them side by side. Keep `horizontal` for two short options, like "Yes" and "No". */
  orientation?: "vertical" | "horizontal";
  /** Disables every radio, and leaves the group out of its form. */
  disabled?: boolean;
  /** Makes every radio required, so its form can't be sent until one is checked. */
  required?: boolean;
}

/** What the group shares with its radios, which they read from it rather than from props of their own. */
export interface RadioGroupContext {
  readonly name: string;
  readonly size: NonNullable<RadioGroupProps["size"]>;
  readonly required: boolean;
  readonly value: string | undefined;
  select(value: string): void;
}

export const RadioGroupKey: InjectionKey<RadioGroupContext> = Symbol("RadioGroup");
</script>

<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from "vue";

const {
  name,
  legend,
  hint,
  error,
  size = "md",
  orientation = "vertical",
  disabled = false,
  required = false,
} = defineProps<RadioGroupProps>();

/** The checked radio's value, which `v-model` binds. Without it, the group keeps its own, and its form submits it. */
const model = defineModel<string>();

defineSlots<{
  /** The radios, one Radio for each option. */
  default?: () => unknown;
}>();

// Getters, so the radios read the group's props as they are on every render.
provide(RadioGroupKey, {
  get name() {
    return name;
  },
  get size() {
    return size;
  },
  get required() {
    return required;
  },
  get value() {
    return model.value;
  },
  select(value: string) {
    model.value = value;
  },
});

// IDs come from the name rather than being made up, so the markup is the same on the
// server, on the client and in every snapshot.
const describedBy = () => [hint && `${name}-hint`, error && `${name}-error`].filter(Boolean).join(" ") || undefined;

const fieldset = useTemplateRef<HTMLFieldSetElement>("fieldset");

onMounted(() => {
  if (import.meta.env.DEV && !legend.trim()) {
    console.warn("[RadioGroup] has no legend: give it one that asks the question its radios answer.", fieldset.value);
  }
});
</script>

<template>
  <fieldset
    ref="fieldset"
    class="radio-group"
    role="radiogroup"
    :data-size="size"
    :data-orientation="orientation"
    :disabled="disabled"
    :aria-labelledby="`${name}-legend`"
    :aria-invalid="error ? 'true' : undefined"
    :aria-describedby="describedBy()"
  >
    <!-- A fieldset groups the radios and takes its name from its legend. ARIA only supports
         aria-invalid on the radiogroup role, not on a fieldset's own group role or on a
         radio, so the fieldset takes that role. HTML-AAM names a fieldset from its legend
         whatever its role, but aria-labelledby says it outright, so the name doesn't
         depend on each browser's fieldset naming. There's no aria-orientation: a
         horizontal group stacks again on narrow screens, which a fixed attribute can't
         follow, and the arrow keys move both ways either way. -->
    <legend :id="`${name}-legend`" data-slot="legend">{{ legend }}</legend>
    <p v-if="hint" :id="`${name}-hint`" data-slot="hint">{{ hint }}</p>
    <!-- Above the radios, so people read what's wrong before they choose again. -->
    <p v-if="error" :id="`${name}-error`" data-slot="error">{{ error }}</p>
    <!-- The radios get a box of their own, so laying them out in a row leaves the hint
         and the error above them. -->
    <div data-slot="options">
      <slot />
    </div>
  </fieldset>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The group's styles go in `components`,
   so they win over the resets in `base`, and give way to utilities and to any
   style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

@layer components {
  .radio-group {
    --radio-group--gap: 0.25rem;
    /* Set per size. The text sizes are the Input's, so a group sits well in a form with one. */
    --radio-group--font-size: 0.875rem;
    /* The space between radios side by side, wide enough that each label reads as its own radio's. */
    --radio-group--options--column-gap: 1.5rem;

    display: flex;
    flex-direction: column;
    gap: var(--radio-group--gap);
    /* A fieldset is never narrower than its content on its own, so it would overflow a narrow column. */
    min-width: 0;
    margin: 0;
    padding: 0;
    border: 0;
    color: var(--color--neutral-text);
    font-family: inherit;
    font-size: var(--radio-group--font-size);
    line-height: 1.25;
  }

  /* Sizes */
  .radio-group[data-size="xs"] {
    --radio-group--font-size: 0.75rem;
  }
  .radio-group[data-size="lg"],
  .radio-group[data-size="xl"] {
    --radio-group--font-size: 1rem;
  }

  /* Parts */
  .radio-group > [data-slot="legend"] {
    margin-block-end: var(--radio-group--gap);
    padding: 0;
    font-weight: 500;
  }
  .radio-group > [data-slot="hint"],
  .radio-group > [data-slot="error"] {
    margin: 0;
  }
  .radio-group > [data-slot="hint"] {
    /* The text colour at 70%, which has 4.5:1 against the page, like the Input's placeholder. */
    color: color-mix(in srgb, currentColor 70%, transparent);
  }
  .radio-group > [data-slot="error"] {
    color: var(--color--error-text);
  }
  .radio-group > [data-slot="options"] {
    display: flex;
    flex-direction: column;
    /* Each radio is as wide as its label, so the space beside it doesn't select it. */
    align-items: flex-start;
    gap: var(--radio-group--gap);
  }

  /* Orientations. Side by side from 640px up, and stacked on narrower screens, like phones,
     as GOV.UK's inline radios are. They wrap onto the next line if the row runs out of room. */
  @media (min-width: 40rem) {
    .radio-group[data-orientation="horizontal"] > [data-slot="options"] {
      flex-flow: row wrap;
      column-gap: var(--radio-group--options--column-gap);
    }
  }
}
</style>

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 screenRadio Group
Pick one option out of many, like a countrySelect
Pick any number of options, from none to all of themCheckbox, in a group
Agree to one thing, like the termsCheckbox, on its own
Turn a setting on or off, with an effect they see right awaySwitch
Answer yes or no to a question in a form, which is sent laterRadio 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.

Sources

Copyright © 2026