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, from its parts and sizes to its states, layout and tokens.

User Experience (UX)

How a radio group behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.

Developer Experience (DX)

How developers use the Radio Group, with one consistent, typed API that works with your forms however you build them.

Agentic Experience (AX)

How agents read, fill in and check a radio group, from its accessibility tree to its contract, its DOM and its tests.

At a glance

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

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.

You'll find the rules for each layer on its page:

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>

Sources

Copyright © 2026