User Experience (UX)
Now let's look at how a checkbox group behaves for every person, input and setting. A good checkbox group is accessible, predictable, designed in every state and adaptive.
Accessible
Use a fieldset and a legend
A <fieldset> gives you the group role and disabled for every control inside it, and a <legend> as its first child gives the group its name, all for free. Screen readers announce the legend as people move into the group, so they hear "Which emails would you like?" before "Product news, checkbox, not checked", rather than a list of boxes with no question. A <div> with a paragraph above it only looks like a group, and a <div role="group"> still needs aria-labelledby to be named, and can't disable anything.
{/* Do: the legend names the group, and screen readers announce it with each box */}
<fieldset>
<legend>Which emails would you like?</legend>
<label><input type="checkbox" name="emails" value="news" /> Product news</label>
<label><input type="checkbox" name="emails" value="tips" /> Tips and guides</label>
</fieldset>
{/* Don't: the question is just a paragraph, so the boxes are announced without it */}
<div>
<p>Which emails would you like?</p>
<label><input type="checkbox" name="news" /> Product news</label>
<label><input type="checkbox" name="tips" /> Tips and guides</label>
</div>
The legend has to be the <fieldset>'s first child, which the HTML Standard asks for, and which is what names the group. The Checkbox Group renders its legend there for you, and resets the fieldset's border and padding, so it lines up with the rest of your form, without the browser's frame around it.
Name and describe the group
The legend says what the options are for, and it's best written as the question they answer, like "Which emails would you like?". It's the group's accessible name, so screen readers read it as people move into the group, voice control users can say it, and tests and agents use it to find the group.
Pick as many as you like. You can stop them at any time.
<CheckboxGroup name="emails" options={emails} aria-describedby="emails-hint">Which emails would you like?</CheckboxGroup>
<p id="emails-hint">Pick as many as you like. You can stop them at any time.</p>
- Every group has a visible legend (WCAG 1.3.1, 3.3.2). Keep it short, since screen readers can repeat it for each box, and put anything longer in the hint.
- A hint, like "Pick as many as you like", goes in the group's description. Keep it out of the legend, so it doesn't become part of the name, and point
aria-describedbyon the group at itsid, which is what the APG asks for a group's descriptive text. - An error message goes in the description too, after the hint, so the group's
aria-describedbylists both IDs, the hint's first (see Pick at least one). - Each option is a Checkbox, so its label follows the Checkbox's rules, from
checkbox/visible-labeltocheckbox/positive-label. The options take a value and a label and nothing else, so a hint about one option goes in the group's hint, naming the option it's about.
Contrast and size
- The legend needs a contrast of at least 4.5:1 with its background (WCAG 1.4.3). A disabled group is exempt, but it should still be readable.
- Each box, its focus ring and its hit area follow the Checkbox's Contrast and size.
- Stacked options sit 8px apart, so a tap that lands between two of them doesn't toggle the wrong one, and every option keeps its own 24 × 24px hit area (WCAG 2.5.8).
Predictable
Keyboard
| Key | Checkbox Group |
|---|---|
Tab, Shift+Tab | Moves focus to the next or previous checkbox, select-all box included |
Space | Checks or unchecks the focused checkbox |
| Arrow keys | Don't move focus between checkboxes |
Every checkbox in a group is a stop of its own in the tab order, which is what the APG's mixed-state example does, and what native checkboxes do. That's different from a Radio Group, where Tab reaches the group once and the arrow keys move between its options, because only one of them can be checked. In a checkbox group, people check each box on its own, so each one gets its own stop, and you don't need any roving focus to build one.
Select all
A select-all box lets people check or clear every option at once, which saves a lot of clicks in a long list. It's a Checkbox above the options that shows what they add up to: checked when every option is, unchecked when none is, and mixed, with a dash, when only some are. Screen readers announce it as "mixed", or "partially checked", so people know what it stands for before they press it.
You'll get 1 of 3
const [picked, setPicked] = useState(["news"]);
<CheckboxGroup name="emails" options={emails} value={picked} onValueChange={setPicked} selectAll="All emails">
Which emails would you like?
</CheckboxGroup>
<p>You'll get {picked.length} of {emails.length}</p>
- Checking it, from unchecked or from mixed, checks every option, and unchecking it clears them all, which is what the APG asks for. Try it above: check one option, and the box turns mixed, then press it, and every option is checked.
- It doesn't go back to the options people had picked before, the way the APG's own example does as a third step. The APG leaves that up to you, and we leave it out, since a box that does something different on every third press is hard to predict, and it's never clear which earlier selection it'll bring back.
- Its state is worked out from the options every time they change, never kept on its own, so it can't disagree with them, as
checkbox/derived-mixedasks. - It has no
name, so its form never sends it. It stands for the options, and they're what the form sends. - Label it with what it selects, like "All emails", rather than "Select all" on its own, so it still makes sense out of context. It's best kept for longer lists, or for lists that people often want all of, since in a group of two or three it's one more box to read.
Keep options in a column
People read a checkbox group from top to bottom, one option per line, and that's how the Checkbox Group lays them out unless you ask for a row (see Orientation). A column keeps each box next to its own label, lets long labels wrap under themselves, and still fits on a phone, which a row of more than a few options doesn't.
Order the options in a way people can predict, like from most to least common, or alphabetically for a long list, and keep the same order every time the group shows up, so people who've used it before find each option where they left it.
Every state
Disabled
disabled on the Checkbox Group sets disabled on its <fieldset>, which disables every checkbox inside it, select-all box included. None of them can be focused, toggled or sent with the form, and each one looks disabled on its own, since the Checkbox's look comes from :disabled, which matches inside a <fieldset disabled> too (see checkbox/disabled-inert). The legend fades with them, so the whole group reads as one that's out of use.
As with a single checkbox, we recommend disabling as little as you can, since keyboard users can't reach a disabled group to find out why it's disabled (checkbox/explain-disabled). Explain why next to it, or leave the group out until it applies.
Pick at least one
Most checkbox groups are optional, since checking nothing is an answer too. Sometimes people have to pick at least one, though, like choosing which emails to sign up for on a page that's only there to sign up. That's a rule about the group, and HTML has no way to say it. required on a checkbox means that box has to be checked, so putting it on every box asks people to check them all, and putting it on one box asks for that one in particular. So say it in the hint, check it in your own code when people submit, and report it on the group.
import { useRef, useState, type FormEvent } from "react";
import { flushSync } from "react-dom";
export function SignUp() {
const [picked, setPicked] = useState<string[]>([]);
const [error, setError] = useState("");
const [status, setStatus] = useState("");
const group = useRef<HTMLFieldSetElement>(null);
function update(value: string[]) {
setPicked(value);
// Picking one fixes the error, so it clears right away.
if (value.length) setError("");
}
async function signUp(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!picked.length) {
// Focus the first box once the error is in the page, so screen readers read it with the group.
flushSync(() => {
setError("Pick at least one email to sign up for");
setStatus("Couldn't sign you up. Pick at least one email.");
});
group.current?.querySelector("input")?.focus();
return;
}
await api.signUp(picked);
setStatus("You're signed up");
}
return (
<form noValidate onSubmit={signUp}>
<CheckboxGroup
ref={group}
name="emails"
options={emails}
value={picked}
onValueChange={update}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "emails-hint emails-error" : "emails-hint"}
>
Which emails would you like?
</CheckboxGroup>
<p id="emails-hint">Pick at least one.</p>
{error && <p id="emails-error">{error}</p>}
<Button type="submit" color="primary">Sign up</Button>
<p role="status">{status}</p>
</form>
);
}
- Say that people need to pick one before they try, in the hint or in the legend, like "Pick at least one" (WCAG 3.3.2). There's no
requiredto announce it for you. - Check it when people submit, rather than as they move through the boxes, since every box is unchecked until they've picked one. Once the error shows, clear it as soon as they check a box.
- Set
aria-invalid="true"on the group while its error message shows, and add the message to the group'saria-describedby, after the hint (WCAG 3.3.1). The Checkbox Group passesaria-invalidon to each option, so every box shows the invalid border and is announced as invalid as people reach it, since not every screen reader reads a group's description when focus moves into it. - Say what to pick, as in "Pick at least one email to sign up for", rather than "This field is required" (WCAG 3.3.3).
- When a submit fails, move focus to the group's first checkbox, once the error is in the page, as on the Checkbox. The status message says what happened even when the focus doesn't move.
- Put
novalidateon the form, as on the Checkbox and the Input, so the browser's own error bubbles don't compete with yours. The same goes for a group with a limit, like at most three options: check it in your code and report it on the group.
Adaptive
Pointer, touch and pen
- Each option toggles from anywhere in its box's hit area or on its label, as described in the Checkbox's Pointer, touch and pen. A press on the legend does nothing, since it names the group rather than one of its boxes.
- Stacked options sit 8px apart, and options in a row 24px apart, so a tap that lands between two doesn't toggle the wrong one.
More contrast
When someone asks for more contrast (prefers-contrast: more), each box's border is drawn at full strength, as on the Checkbox. The legend already passes, so it stays as it is.
Forced colours
Windows contrast themes replace every colour with the user's own. Each box keeps its own forced-colours look, as on the Checkbox, and a disabled group's legend is drawn in GrayText, the system's colour for disabled text, like its boxes.
Colour scheme
The tokens use light-dark(), which follows color-scheme, so the Checkbox Group adapts to your light and dark themes on its own.
Text size and spacing
The legend, the gaps and the indent are set in rem, so the whole group grows with its text at 200% text size (WCAG 1.4.4) and with wider line, letter and word spacing (WCAG 1.4.12). A long legend wraps rather than being clipped, and a row of options wraps onto the next line rather than scrolling sideways at 320px wide (WCAG 1.4.10).
Languages and direction
The Checkbox Group indents its options with padding-inline-start, and each Checkbox lays out its box and label in a grid that follows the reading direction, so the whole group mirrors in right-to-left languages, with no extra work on your part:
<div dir="rtl" lang="ar">
<CheckboxGroup name="emails" options={emails} defaultValue={["news"]} selectAll="كل الرسائل">
ما الرسائل التي تريدها؟
</CheckboxGroup>
</div>
- The options sit in from the select-all box on the right in a right-to-left layout, since the indent is on the start side.
- Remember to translate the legend, the options, the hint and the error message along with the rest of the page, and to mark any that are in another language with
lang, so that screen readers pronounce them correctly (WCAG 3.1.2).
UX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
checkbox-group/fieldset | Must | Component | It's a <fieldset> whose first child is the <legend> that names it, so it has the group role and the legend as its name | WCAG 1.3.1, 4.1.2 (A), HTML | Unit test |
checkbox-group/legend-question | Must | Usage | Every group has a visible legend that says what its options are for, ideally as the question they answer | WCAG 1.3.1, 3.3.2 (A) | Review |
checkbox-group/described | Should | Both | A hint and an error message sit outside the legend and are linked to the <fieldset> with aria-describedby, the hint's ID first | WCAG 1.3.1 (A), APG | Unit test, screen reader |
checkbox-group/keyboard | Must | Component | Tab reaches each box in turn, select-all box included, Space toggles the focused one, and the arrow keys never move focus between them | WCAG 2.1.1 (A), APG | Unit test |
checkbox-group/select-all-state | Must | Component | The select-all box is checked when every option is, unchecked when none is, and mixed when only some are, worked out from them whenever they change | WCAG 4.1.2 (A), APG | Unit test |
checkbox-group/select-all-action | Must | Component | Checking the select-all box, from unchecked or mixed, checks every option, and unchecking it clears them all, without going back to an earlier selection | APG, Open Components | Unit test |
checkbox-group/select-all-label | Should | Usage | A select-all box says what it selects, like "All emails", and only comes with lists that are long or often picked in full | Open Components | Review |
checkbox-group/vertical | Should | Usage | Options run in a column, and only two or three short ones run in a row | Open Components | Review |
checkbox-group/predictable-order | Should | Usage | Options come in an order people can predict, like most to least common or alphabetical, and keep it every time | Open Components | Review |
checkbox-group/at-least-one | Must | Usage | A group people have to pick from says so in its legend or hint, and is checked in code when the form is sent, never with required on its boxes | HTML, WCAG 3.3.2 (A) | Review |
checkbox-group/error-message | Must | Usage | An invalid group has an error message, in text and linked to the <fieldset> with aria-describedby, that says what to pick | WCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA) | Screen reader |
checkbox-group/invalid-state | Must | Both | The group sets aria-invalid="true" while its error message shows, and each of its options looks and reads invalid with it | WCAG 4.1.2 (A), Open Components | Unit test |
checkbox-group/focus-on-error | Should | Usage | A failed submit moves focus to the group's first box, once the error is in the page | Open Components, beyond WCAG 3.3.1 (A) | Keyboard |
checkbox-group/disabled-inert | Must | Component | A disabled group sets disabled on its <fieldset>, so no box in it, select-all box included, can be focused, toggled or sent | HTML | Unit test |
checkbox-group/mirrors-in-rtl | Should | Component | The indent and a row of options follow dir, with logical properties | Open Components | Review |
FAQ
Checkbox Group or something else?
A Checkbox Group lets people pick any number of options from a short list. For anything else, another component will do a better job:
| When people need to… | Use |
|---|---|
| Pick any number of options out of a few | Checkbox Group |
| Say yes or no to one thing, like agreeing to the terms | Checkbox |
| Pick one option out of a few | Radio Group |
| Pick one option, or several, out of many | Select, or Combobox |
| Flip settings that take effect right away, like alerts | A Switch for each |
| Select rows in a table | A Checkbox in each row, with an aria-label |
The Radio Group, the Select and the Combobox are on our Roadmap.
required on a checkbox means that one box has to be checked, so a form with required on every box asks for all of them, and the browser's message names one box rather than the group. A radio group is different, since one required radio makes the whole group required. So for a checkbox group, say "Pick at least one" in the hint, check the value when people submit, and show the error on the group, as under Pick at least one.Space toggles the one that has focus, which is how native checkboxes have always worked.