“Choose a delivery option” displayed in red above a set of radio buttons is invisible to a screen-reader user who tabs into the group: focus lands on the first (or checked) radio, the label is read, and nothing connects the error to what they are hearing — so they press submit again and get the same unexplained failure.

Groups need their own error wiring, because the error belongs to the question, not to any single option. This page, part of ARIA live regions for form errors, builds the markup for radio groups and checkbox groups — including “choose at least one” — so the question, the hint and the error are read together when users enter the group.


Context and prerequisites

The semantics that do the work:

  • <fieldset> and <legend> group related controls and give the group a name. Screen readers read the legend when focus first enters the group.
  • aria-describedby on the fieldset adds a description to the group. Support for reading group descriptions on entry is good in current NVDA, JAWS and VoiceOver, but not universal — so the error should also be reachable another way.
  • Radio groups are one Tab stop; arrow keys move between options. Focus enters on the checked option, or the first one if none is checked.
  • aria-invalid is defined for form controls; on a fieldset it is not reliably announced. Put it on the controls, and only where it is true.
  • required on one radio makes the group required natively; for checkbox groups, “at least one” has no native equivalent.

The robust pattern combines group-level description with a link in the error summary that lands on the first option, so users reach the error through at least one route that every screen reader supports.

Where each part of a group error goes The question belongs in the legend, which names the group. The hint belongs in a paragraph referenced by the fieldset's aria-describedby. The error message belongs in a paragraph inside the fieldset, also referenced by aria-describedby, placed before the hint. The invalid state belongs on each option input, set only while the group is in error. The required state for radio groups comes from required on the inputs; for checkbox groups it is stated in the legend or hint text. Part Element Why question <legend> read on entering the group hint <p> in describedby context before answering error <p> in describedby, before hint read with the group invalid state aria-invalid on each input fieldset support is unreliable required required (radio) / text (checkbox) announced as required

The core pattern: fieldset wiring, updated on error

<fieldset id="delivery" aria-describedby="delivery-error delivery-hint">
  <legend>How would you like your order delivered?</legend>
  <p id="delivery-hint" class="hint">Next day delivery costs £4.99.</p>
  <p id="delivery-error" class="error" hidden>
    <span class="visually-hidden">Error: </span>Choose a delivery option.
  </p>
  <div class="option">
    <input type="radio" id="delivery-standard" name="delivery" value="standard" required>
    <label for="delivery-standard">Standard (3–5 working days)</label>
  </div>
  <div class="option">
    <input type="radio" id="delivery-next" name="delivery" value="next-day">
    <label for="delivery-next">Next day</label>
  </div>
</fieldset>
export function setGroupError(fieldset: HTMLFieldSetElement, message: string | null) {
  const errorEl = fieldset.querySelector<HTMLElement>(".error")!;
  const inputs = [...fieldset.querySelectorAll<HTMLInputElement>("input[type=radio], input[type=checkbox]")];
  if (message) {
    errorEl.lastChild!.textContent = message;        // keep the hidden "Error:" prefix
    errorEl.hidden = false;
    // aria-invalid on the controls: the fieldset itself is not reliably announced.
    inputs.forEach((i) => i.setAttribute("aria-invalid", "true"));
  } else {
    errorEl.hidden = true;
    inputs.forEach((i) => i.removeAttribute("aria-invalid"));
  }
}

// "At least one" for checkboxes: no native constraint, so validate in code.
export function validateCheckboxGroup(fieldset: HTMLFieldSetElement, message: string) {
  const checked = fieldset.querySelectorAll("input[type=checkbox]:checked").length;
  setGroupError(fieldset, checked === 0 ? message : null);
  return checked > 0;
}

// Clear the group error as soon as any option is chosen (reward early).
export function wireGroup(fieldset: HTMLFieldSetElement, validate: () => boolean) {
  fieldset.addEventListener("change", () => {
    if (!fieldset.querySelector(".error")!.hasAttribute("hidden")) validate();
  });
}

The error summary links to the first option’s id (#delivery-standard), so following the link puts focus inside the group, where the legend and description are read.


Step-by-step walkthrough

  1. Wrap the options in a fieldset with a legend that asks the question. The legend is the group’s accessible name; make it the full question, not a one-word heading.
  2. Put the error inside the fieldset and reference it first in aria-describedby. Keep it hidden until needed; unhide and fill it on error. Reference hint and error IDs on the fieldset, error first.
  3. Add a visually hidden “Error:” prefix. Colour alone does not tell users this paragraph is an error, per error styling that does not rely on colour; the prefix does so in speech.
  4. Set aria-invalid on the options while the group is in error. Remove it the moment an option is chosen.
  5. Link the summary to the first option. Focus lands in the group and the group’s name and description are read.
  6. Validate “at least one” in code for checkbox groups. Use the group rule from requiring at least one of several fields, and state the requirement in the legend or hint.

Why the error belongs to the group, not to one option

Attaching the error to the first radio — the quickest fix — makes it the first option’s description. A user who arrows to the second option no longer hears it, and one who navigates with the rotor straight to “Next day” never hears it at all. Worse, aria-invalid on only the first radio suggests that option specifically is wrong, which is nonsense for a question with no answer yet. The question is what failed, so the error is read with the question: on the fieldset, alongside the legend, whichever option the user lands on.

Following the summary link into a group The user activates the summary link choose a delivery option. Focus moves to the first radio in the group. Because focus has entered the fieldset, the screen reader reads the legend, how would you like your order delivered, then the group description beginning with error, choose a delivery option, then the focused option's label, standard, radio button, not checked, invalid. The user presses the down arrow, selects next day, and the error is removed. User Error summary Radio group Screen reader activate "Choose a delivery option" focus → first radio legend + "Error: Choose a delivery option" "Standard… radio, not checked, invalid" Down arrow → Next day error removed

Failure modes and edge cases

1. Custom-styled controls losing semantics

Hiding native inputs with display: none and styling <div>s removes them from the keyboard and accessibility tree. Visually hide the native input (keep it focusable) and style the label, or use appearance: none on the input itself.

2. Legends styled away

Some designs remove the legend and put the question in a heading above the fieldset. Screen readers then announce the group without a name. Keep the legend; style it as a heading if needed, or put the heading inside the legend.

3. Dynamic groups

When options load asynchronously, the group may be empty when validation runs. Do not show “Choose an option” for a group that has no options yet; show a loading state instead.

4. Very long option lists

Twenty checkboxes with a group error: users may not re-enter the group from the top. The summary link and the description on entry both help; also consider placing the error immediately after the legend so it is visually near the question.

5. Group descriptions not read

A few screen reader and browser combinations do not read fieldset descriptions on entry. The summary route and the per-option aria-invalid still convey the problem; confirm with the screen-reader testing matrix for form errors.

Three routes to the same error The group description route reads the error with the legend when focus enters the fieldset. The summary route lists the error after submit and moves focus into the group when its link is activated. The invalid state route marks each option as invalid, so even a user who reaches an option by other means hears that something is wrong. Together they cover screen readers that do not support one route. Group description Read with the legend on entry. Summary link After submit; focus into the group. aria-invalid on options Heard on any option reached.

Verification checklist


Frequently Asked Questions

Should I use role="radiogroup" instead of fieldset?

For native radio inputs, fieldset and legend are the better choice: they work without ARIA and name the group reliably. role="radiogroup" is for custom radio implementations, where you also take on keyboard handling — see roving tabindex for option groups.

Is required on one radio enough?

Natively, yes: a radio group is required if any radio in it has required. Screen readers typically announce “required” for each option. Add the requirement to the legend or hint text too, for users who do not hear it.

Where should the error go visually?

Between the legend (and hint) and the options, so it is seen before the choices and next to the question it concerns. Pair colour with an icon and the “Error:” text for users who cannot rely on colour.


Related

← ARIA Live Regions for Form Errors