Validating Radio Groups and Fieldsets
Radio groups and checkbox sets break most validation code written for text fields. The requirement belongs to the group (“choose a delivery option”), but validity lives on the individual inputs. A required radio group marks every radio invalid, so naive error rendering shows three copies of the same message. Checkbox sets have no native “at least one” constraint at all. And the natural place for the message — next to the group’s <legend> — is not attached to any input. This recipe validates both kinds of group with the Constraint Validation API: required on radios, a custom rule for checkbox minimums and maximums, one message per group rendered in the fieldset and linked for assistive technology, and focus that lands on the right option when reportValidity() runs.
When You Need Group-Level Validation
Any question with a set of options needs it:
- Single choice (radio group) — delivery speed, account type, “How did you hear about us?”.
- Multiple choice with a minimum (checkbox set) — “Choose at least one topic”.
- Multiple choice with a maximum — “Choose up to three interests”.
- Consent sets — several required checkboxes in one fieldset, where each is individually required.
Groups should always be real <fieldset> elements with a <legend>, which gives screen readers the question when focus enters any option. That structure is also what makes group-level error styling possible with fieldset:has(:user-invalid), as described in styling validation states with the :has() selector.
Minimal Working Group Validation
<form id="prefs" novalidate>
<fieldset id="delivery" aria-describedby="delivery-err">
<legend>Delivery speed</legend>
<p id="delivery-err" class="field-error" hidden></p>
<label><input type="radio" name="delivery" value="standard" required> Standard (3–5 days)</label>
<label><input type="radio" name="delivery" value="express"> Express (next day)</label>
<label><input type="radio" name="delivery" value="pickup"> Pick up in store</label>
</fieldset>
<fieldset id="topics" aria-describedby="topics-hint topics-err" data-min="1" data-max="3">
<legend>Topics you're interested in</legend>
<p id="topics-hint" class="hint">Choose between 1 and 3.</p>
<p id="topics-err" class="field-error" hidden></p>
<label><input type="checkbox" name="topics" value="forms"> Forms</label>
<label><input type="checkbox" name="topics" value="a11y"> Accessibility</label>
<label><input type="checkbox" name="topics" value="testing"> Testing</label>
<label><input type="checkbox" name="topics" value="perf"> Performance</label>
</fieldset>
<button type="submit">Save preferences</button>
</form>
const form = document.querySelector<HTMLFormElement>("#prefs")!;
/** Custom min/max rule for a checkbox set, reported on the FIRST checkbox (the group's anchor). */
function checkCheckboxSet(set: HTMLFieldSetElement): void {
const boxes = [...set.querySelectorAll<HTMLInputElement>("input[type=checkbox]")];
const chosen = boxes.filter((b) => b.checked).length;
const min = Number(set.dataset.min ?? 0);
const max = Number(set.dataset.max ?? Infinity);
const message =
chosen < min ? `Choose at least ${min} topic${min > 1 ? "s" : ""}.` :
chosen > max ? `Choose up to ${max} topics. You've chosen ${chosen}.` : "";
boxes.forEach((b, i) => b.setCustomValidity(i === 0 ? message : "")); // one carrier per group
}
/** Render one message per group, in the fieldset, regardless of how many inputs failed. */
function renderGroupErrors(show: boolean): void {
for (const set of form.querySelectorAll<HTMLFieldSetElement>("fieldset")) {
const failing = set.querySelector<HTMLInputElement>("input:invalid");
const out = set.querySelector<HTMLElement>(".field-error")!;
const message = failing
? failing.validity.valueMissing && failing.type === "radio"
? `Choose a ${set.querySelector("legend")!.textContent!.trim().toLowerCase()}.`
: failing.validationMessage
: "";
out.textContent = show ? message : "";
out.hidden = !show || !message;
set.toggleAttribute("data-invalid", show && Boolean(message));
}
}
form.addEventListener("change", (e) => {
const set = (e.target as HTMLElement).closest<HTMLFieldSetElement>("fieldset");
if (set?.dataset.min || set?.dataset.max) checkCheckboxSet(set);
renderGroupErrors(form.hasAttribute("data-submitted")); // live updates after the first submit
});
form.addEventListener("submit", (e) => {
e.preventDefault();
form.setAttribute("data-submitted", "");
form.querySelectorAll<HTMLFieldSetElement>("fieldset[data-min], fieldset[data-max]").forEach(checkCheckboxSet);
renderGroupErrors(true);
if (form.reportValidity()) form.submit(); // focuses the first radio / first checkbox of a failing group
});
The key move is choosing one carrier input per group for the custom checkbox rule — the first checkbox — so reportValidity() focuses the group’s first option and the browser’s message appears once. For radio groups the browser already treats the group as a unit: checking any radio makes every radio valid, and focus lands on the first radio of an unanswered group (or the checked one, if any).
Group Validation Reference
| Technique | Applies to | Effect | Notes |
|---|---|---|---|
required on a radio |
Radio group (same name) |
Group must have a checked radio | Put it on at least one radio; all is clearer |
required on a checkbox |
Single checkbox | That box must be ticked | Consent boxes; not “at least one” |
| Custom rule on first checkbox | Checkbox set | Min / max selections | setCustomValidity on one carrier |
aria-describedby on fieldset |
Any group | Message read with the group | Also works on role="radiogroup" |
fieldset:has(:user-invalid) |
Any group | Group-level error styling | See the :has() guide |
fieldset disabled |
Any group | Excludes all inputs from validation | For conditional groups |
Verification Steps
import { test, expect } from "@playwright/test";
test("group errors appear once per fieldset", async ({ page }) => {
await page.goto("/prefs");
await page.getByRole("button", { name: "Save preferences" }).click();
await expect(page.locator("#delivery-err")).toHaveText("Choose a delivery speed.");
await expect(page.locator("#delivery .field-error")).toHaveCount(1);
await expect(page.getByLabel("Standard (3–5 days)")).toBeFocused();
for (const t of ["Forms", "Accessibility", "Testing", "Performance"]) await page.getByLabel(t).check();
await expect(page.locator("#topics-err")).toHaveText("Choose up to 3 topics. You've chosen 4.");
});
Edge Cases and Failure Modes
Duplicate summaries. An error summary built from invalid events lists every radio in an empty group. Group failures by name before rendering the summary, as in listening for the invalid event.
Radios with different name values. A radio group is defined by a shared name within one form. Typos in name split the group, so required on one radio does not cover the others. Check names when a group “never validates”.
Custom-styled radios. Hiding native radios with display:none removes them from focus and from reportValidity()'s focus target. Visually hide them instead (opacity or clip) and style the label.
Conditional groups. A group that only applies when another answer is chosen must be removed or disabled when hidden; a hidden required radio group blocks submission invisibly. The pattern is in conditional field validation on selection.
Keyboard Behaviour and Focus Inside Groups
Radio groups and checkbox sets have different keyboard models, and validation focus has to respect both. A radio group is one tab stop: Tab moves into the group (onto the checked radio, or the first one), arrow keys move and select between options, and Tab leaves the group. So when reportValidity() focuses the first radio of an unanswered group, the user can arrow through the choices immediately, and selecting one clears the error. A checkbox set is several tab stops, one per box, and Space toggles each. Focusing the first checkbox for a “choose at least one” error is right because it puts the user at the top of the list they need to work through.
Error messages must not break these models. Do not insert focusable elements (links, buttons) between radios, which splits the arrow-key sequence; keep the message before the options, under the legend, where it is read when focus enters the group. And do not move focus when the error clears — the user is in the middle of choosing, and a focus jump to “the next field” would be disorienting. The broader focus rules are in managing focus after validation failure.
Server Errors for Groups
When the server rejects a group’s answer — a delivery option that became unavailable, a topic that was retired — its error arrives keyed by the group’s name. Resolving that name with form.elements.namedItem("delivery") returns a RadioNodeList, not a single input; put the custom validity on the first radio (or the checked one) and render the message in the fieldset, exactly as for client-side errors. Clear it when any radio in the group changes, because a group-level server error describes the group, not one option.
Groups as Single Custom Controls
Some designs replace a radio group with a segmented control or a card picker. If you build one, keep native radios underneath and style them, or build a form-associated custom element that owns the group’s value and validity as one control. Both approaches preserve the group semantics that screen readers and reportValidity() rely on; what breaks things is a set of clickable <div>s with a hidden input, which has no roles, no keyboard support and no participation in validation. The custom-element route is covered in form-associated custom elements, whose rating example is exactly a single-choice group.
Frequently Asked Questions
How do I make a radio group required?
Add the required attribute to the radios in the group (one is enough; adding it to all is clearer). The group is then invalid until any radio with that name is checked.
How do I require at least one checkbox in a group?
There is no native attribute for it. Count checked boxes in a change handler and call setCustomValidity with a message on the first checkbox when the count is below the minimum, and with an empty string otherwise.
Why does my error message appear once per radio button?
Every radio in an unanswered required group is individually invalid. Render errors per fieldset instead of per input, and de-duplicate by name when building an error summary.
Where should a radio group's error message go?
Inside the fieldset, typically just after the legend, and referenced from the fieldset's aria-describedby so it is announced when focus enters the group.
Related Guides
- Constraint Validation API Deep Dive — validity on individual controls.
- Listening for the invalid Event — collecting failures for a summary.
- Styling Validation States with the :has() Selector — group-level styling.
- Conditional Field Validation on Selection — groups that appear and disappear.