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.

Radio groups versus checkbox sets Two columns comparing native validation support for required radio groups with checkbox sets that need a custom at-least-one rule. Radio group ✓ required on any radio makes the group required ✓ checking any radio satisfies every radio • every radio is invalid while none chosen ✗ naive rendering repeats the message per radio Checkbox set ✗ required means "this box must be ticked" ✗ no native "at least one of these" ✓ custom rule via setCustomValidity on the first box ✓ same rule can enforce a maximum
Radio groups have native group semantics for required; checkbox sets need a small custom rule to express "at least one".

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

From per-input validity to one group message Individual radios or checkboxes report validity, a group renderer finds the first failing input in each fieldset, and a single message is written into the fieldset and linked with aria-describedby. Inputs radio required / custom rule on first box First failing input per fieldset One message in the fieldset, under the legend aria- describedby fieldset → message reportValidity focus first option of the group
Validity stays on the inputs where the browser expects it; the message is lifted to the fieldset where the user expects it.

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.

Radio group with a group-level error A preferences form where the delivery speed radio group has not been answered, showing one error message under the legend and focus on the first option. Your preferences Choose a delivery speed. 1 ✗ Standard (3–5 days) ○ Express (next day) ○ Pick up in store Save preferences 1 The message lives in the fieldset, referenced by its aria-describedby 2 Validity stays on the radios; the first receives focus from reportValidity 3 Choosing any option clears the group message immediately
One message per group, placed under the legend and linked to the fieldset, with focus on the first option.

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.

← Back to Constraint Validation API Deep Dive