“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-describedbyon thefieldsetadds 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-invalidis defined for form controls; on afieldsetit is not reliably announced. Put it on the controls, and only where it is true.requiredon 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.
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
- Wrap the options in a
fieldsetwith alegendthat asks the question. The legend is the group’s accessible name; make it the full question, not a one-word heading. - 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. - 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.
- Set
aria-invalidon the options while the group is in error. Remove it the moment an option is chosen. - Link the summary to the first option. Focus lands in the group and the group’s name and description are read.
- 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.
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.
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.