Accessibility Developer Kit · Guide 15 / 20

Accessible Checkbox & Radio Group

Native groups with <fieldset> and <legend>, exclusive radio groups, custom-styled inputs that stay accessible, and the indeterminate “select all” tri-state pattern — Vanilla & React.

  • WCAG: 1.3.1 · 1.4.11 · 2.1.1 · 4.1.2
  • Level: Beginner → Intermediate
  • Icons: lucide-react (Check)
1

What we are building

Native checkbox and radio controls, grouped so SR users know what the choice is about.

What we are building

Native checkbox and radio inputs already give you keyboard, SR, focus, state, and form submission for free. The only things you must do: group related controls in a fieldset with a legend, share a name across radios in the same group, and if you want custom visuals, keep the native input in the DOM (visually hidden, not removed).

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Checkbox group

Preferred
Live example
Email notifications

SR announces: “Email notifications, group, Product updates, checked, checkbox, 1 of 3”.

HTML · Accessible
<!-- fieldset + legend groups them; SR announces group on EVERY option -->
<fieldset>
  <legend>Email notifications</legend>
  <label><input type="checkbox" name="prefs" value="product"> Product updates</label>
  <label><input type="checkbox" name="prefs" value="security"> Security alerts</label>
  <label><input type="checkbox" name="prefs" value="marketing"> Marketing &amp; promotions</label>
</fieldset>

fieldset + legend groups them. SR announces the legend before each option so users know what the choice is for.

Variant B

Radio group

Preferred
Live example
Shipping speed

Tab enters the group. ↑ ↓ moves AND selects. All radios share the same name.

HTML · Accessible
<!-- All radios share the same name — browser handles exclusivity -->
<fieldset>
  <legend>Shipping speed</legend>
  <label><input type="radio" name="speed" value="std"> Standard (5-7 days)</label>
  <label><input type="radio" name="speed" value="exp" checked> Express (2 days)</label>
  <label><input type="radio" name="speed" value="ovn"> Overnight</label>
</fieldset>

All radios in the group share the same `name`. Browser handles exclusivity; arrow keys move AND select between options.

Variant C

Custom-styled

When needed
Live example

The native input is still there (focusable, keyboard, SR). Only the visual box is styled.

HTML · Accessible
<!-- Native input visually hidden (clip pattern) + styled sibling span -->
<label class="cstm">
  <input type="checkbox" class="cstm__input">
  <span class="cstm__box" aria-hidden="true">
    <svg><!-- check icon --></svg>
  </span>
  <span class="cstm__label">I agree to the Terms of Service</span>
</label>

<style>
  .cstm__input {
    position: absolute; width: 1px; height: 1px;
    overflow: hidden; clip: rect(0,0,0,0);
    white-space: nowrap; border: 0;
  }
  .cstm__box { /* visual box */ }
  .cstm__input:checked ~ .cstm__box { /* checked style */ }
  .cstm__input:focus-visible ~ .cstm__box { outline: 3px solid #1c6cbf; }
</style>

Visually hide the native input with the clip pattern (NOT display:none). Style a sibling span driven by the input's :checked state.

Variant D

Indeterminate select-all

Advanced
Live example
Tasks for this sprint

Toggle individual items — the parent flips between checked / indeterminate / unchecked.

HTML · Accessible
<!-- Tri-state: checked / indeterminate / unchecked -->
<label>
  <input id="all" type="checkbox"> Select all
</label>

<label><input type="checkbox" class="child"> Task 1</label>
<label><input type="checkbox" class="child"> Task 2</label>
<label><input type="checkbox" class="child"> Task 3</label>

<script>
  const all = document.getElementById('all');
  const children = document.querySelectorAll('.child');

  function syncParent() {
    const n = [...children].filter(c => c.checked).length;
    all.checked = n === children.length;
    all.indeterminate = n > 0 && n < children.length;   // DOM property, not attribute
  }

  all.addEventListener('change', () => {
    children.forEach(c => (c.checked = all.checked));
  });
  children.forEach(c => c.addEventListener('change', syncParent));
  syncParent();
</script>

Tri-state: all checked / none / some. `indeterminate` is a DOM property — set via ref/JS, not an attribute.

3

Flagship implementation

Reusable CheckboxGroup, RadioGroup and TriStateCheckbox.

Flagship implementation

AccessibleCheckRadio.jsx
import React, { useEffect, useRef, useState } from "react";
import "./AccessibleCheckRadio.css";

/**
 * CheckboxGroup — fieldset-wrapped list of checkboxes with programmatic group label.
 */
export function CheckboxGroup({ legend, items, value = {}, onChange, name }) {
  return (
    <fieldset className="group">
      <legend>{legend}</legend>
      {items.map((it) => (
        <label key={it.value} className="opt">
          <input
            type="checkbox"
            name={name}
            value={it.value}
            checked={!!value[it.value]}
            onChange={(e) => onChange?.({ ...value, [it.value]: e.target.checked })}
          />
          <span>{it.label}</span>
        </label>
      ))}
    </fieldset>
  );
}

/**
 * RadioGroup — one choice from many.
 */
export function RadioGroup({ legend, items, value, onChange, name }) {
  return (
    <fieldset className="group">
      <legend>{legend}</legend>
      {items.map((it) => (
        <label key={it.value} className="opt">
          <input
            type="radio"
            name={name}
            value={it.value}
            checked={value === it.value}
            onChange={(e) => onChange?.(e.target.value)}
          />
          <span>{it.label}</span>
        </label>
      ))}
    </fieldset>
  );
}

/**
 * TriStateCheckbox — select-all with indeterminate state.
 */
export function TriStateCheckbox({ label, children, allChecked, onToggle }) {
  const ref = useRef(null);
  useEffect(() => {
    if (!ref.current) return;
    ref.current.indeterminate = !allChecked.every && allChecked.some;
  }, [allChecked]);
  return (
    <label className="opt">
      <input
        ref={ref}
        type="checkbox"
        checked={allChecked.every}
        onChange={(e) => onToggle(e.target.checked)}
      />
      <span>{label}</span>
    </label>
  );
}
Usage · PreferencesForm.jsx
import { useMemo, useState } from "react";
import { CheckboxGroup, RadioGroup, TriStateCheckbox } from "./AccessibleCheckRadio";

export default function PreferencesForm() {
  const [prefs, setPrefs] = useState({ product: true, security: true, marketing: false });
  const [speed, setSpeed] = useState("exp");

  const TASKS = [
    { id: "a", label: "Design system update" },
    { id: "b", label: "Fix tooltip bug" },
    { id: "c", label: "Dark mode" },
  ];
  const [checked, setChecked] = useState({ a: true, b: false, c: true });

  const state = useMemo(() => {
    const n = TASKS.filter(t => checked[t.id]).length;
    return { every: n === TASKS.length, some: n > 0 && n < TASKS.length };
  }, [checked]);

  return (
    <>
      <CheckboxGroup
        legend="Email notifications"
        name="prefs"
        items={[
          { value: "product",   label: "Product updates" },
          { value: "security",  label: "Security alerts" },
          { value: "marketing", label: "Marketing & promotions" },
        ]}
        value={prefs}
        onChange={setPrefs}
      />

      <RadioGroup
        legend="Shipping speed"
        name="speed"
        items={[
          { value: "std", label: "Standard (5-7 days)" },
          { value: "exp", label: "Express (2 days)" },
          { value: "ovn", label: "Overnight" },
        ]}
        value={speed}
        onChange={setSpeed}
      />

      <fieldset>
        <legend>Tasks for this sprint</legend>
        <TriStateCheckbox
          label="Select all"
          allChecked={state}
          onToggle={(v) => setChecked(Object.fromEntries(TASKS.map(t => [t.id, v])))}
        />
        {TASKS.map(t => (
          <label key={t.id} className="opt">
            <input
              type="checkbox"
              checked={!!checked[t.id]}
              onChange={() => setChecked(c => ({ ...c, [t.id]: !c[t.id] }))}
            />
            <span>{t.label}</span>
          </label>
        ))}
      </fieldset>
    </>
  );
}
4

Keyboard behavior

Natives get this right. Don't override it.

Keyboard behavior

KeyExpected result
TabCheckbox: next focusable. Radio: enters the group then exits to next field.
SpaceToggles checkbox / selects radio
↑ ↓ (radio)Moves AND selects between radios in the same group
Click labelToggles / selects the associated input
5

Screen reader expectations

Group name announced on every option.

Screen reader expectations

  • Checkbox in group: “Email notifications, group, Product updates, checked, checkbox, 1 of 3”
  • Radio in group: “Shipping speed, group, Express, radio button, 2 of 3, selected”
  • Indeterminate parent: “Select all, checkbox, mixed” (varies by SR)
  • Custom-styled: same output as native — visually-hidden input is still in the a11y tree
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.3.1 Info and Relationships (A)fieldset + legend establishes the group programmatically.
1.4.11 Non-text Contrast (AA)Checked/unchecked indicators and focus ring must have 3:1 contrast.
2.1.1 Keyboard (A)Space must toggle; arrow keys must navigate radio groups.
4.1.2 Name, Role, Value (A)Exposes label + role + checked state (including mixed).
2.5.5 Target Size (AA, 2.2)Visible control + label must give a 24×24 tap target (prefer 44×44).
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: assert role=group with legend as name → click radio → assert exclusivity → for indeterminate, assert the DOM property.

Jest + Testing Library + jest-axe

AccessibleCheckRadio.test.jsx
// Jest + @testing-library/react + jest-axe
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import { CheckboxGroup, RadioGroup } from "./AccessibleCheckRadio";

expect.extend(toHaveNoViolations);

test("checkbox group exposes legend as group name", () => {
  function F() {
    const [v, setV] = useState({});
    return (
      <CheckboxGroup
        legend="Email notifications"
        name="prefs"
        items={[{ value: "a", label: "Product updates" }]}
        value={v} onChange={setV}
      />
    );
  }
  render(<F />);
  expect(screen.getByRole("group", { name: "Email notifications" })).toBeInTheDocument();
  expect(screen.getByRole("checkbox", { name: "Product updates" })).toBeInTheDocument();
});

test("radio group: only one can be selected", async () => {
  const user = userEvent.setup();
  function F() {
    const [v, setV] = useState("std");
    return (
      <RadioGroup
        legend="Speed" name="speed"
        items={[
          { value: "std", label: "Standard" },
          { value: "exp", label: "Express" },
        ]}
        value={v} onChange={setV}
      />
    );
  }
  render(<F />);
  await user.click(screen.getByRole("radio", { name: "Express" }));
  expect(screen.getByRole("radio", { name: "Express" })).toBeChecked();
  expect(screen.getByRole("radio", { name: "Standard" })).not.toBeChecked();
});

test("has no axe violations", async () => {
  function F() {
    const [v, setV] = useState({});
    return (
      <CheckboxGroup
        legend="X" name="x"
        items={[{ value: "a", label: "A" }]}
        value={v} onChange={setV}
      />
    );
  }
  const { container } = render(<F />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleCheckRadio.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckboxGroup } from "./AccessibleCheckRadio";

test("Space toggles the focused checkbox", async () => {
  const user = userEvent.setup();
  function F() {
    const [v, setV] = useState({});
    return (
      <CheckboxGroup
        legend="X" name="x"
        items={[{ value: "a", label: "A" }]}
        value={v} onChange={setV}
      />
    );
  }
  render(<F />);
  const cb = screen.getByRole("checkbox", { name: "A" });
  cb.focus();
  await user.keyboard(" ");
  expect(cb).toBeChecked();
});

Playwright + @axe-core/playwright

checkbox-radio.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("checkbox-radio page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-checkbox-radio");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("select-all tri-state flips indeterminate correctly", async ({ page }) => {
  await page.goto("/en/accessible-checkbox-radio");
  const all = page.getByRole("checkbox", { name: /select all/i });
  // Initial: some tasks checked → indeterminate
  await expect(all).toHaveJSProperty("indeterminate", true);
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Groups of related controls wrapped in <fieldset> + <legend>
  • Every input has a visible <label> (clicking label toggles it)
  • All radios in a group share the same name
  • Default-checked state matches the common user intent (e.g. "Email me security alerts" = checked)
  • Custom-styled inputs use the clip pattern — NOT display:none
  • Checked, unchecked, and focus states all have 3:1 contrast
  • Tap target (control + label area) at least 24×24 px (prefer 44×44)
  • Indeterminate parent uses the DOM property, not an attribute
  • Arrow keys select between radios inside a group
  • Zero critical / serious axe violations
9

Common mistakes

The checkbox/radio bugs we see on nearly every audit.

Common mistakes

  • Radios with different name values. They don’t cancel each other out.
  • No fieldset / legend. SR says the option label with no group context.
  • Custom checkbox hidden with display:none. Keyboard can’t focus; SR can’t find.
  • Indeterminate set as attribute. It’s a DOM property — must be set via ref or JS.
  • Click target too small. Label should extend the hit area; include min-height.
  • Checked-state color only. Fails 1.4.11. Pair color with an icon or border change.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo