User Interface (UI)
Let's start with how a checkbox group looks: its parts, its sizes, its orientation and how it shows each of its states.
Anatomy
- Group — The
<fieldset>, which holds the legend and every checkbox, and carries the size and orientation. - Legend — The
<legend>, its first child, which names the group, usually with a question. - Select all — Optional. A Checkbox that checks or clears every option, and shows whether all, some or none are checked.
- Options — A Checkbox for each option, which all share one
name. - Hint and error — Optional. Paragraphs of their own, after the group, which its
<fieldset>points to by theirid.
Every group has a legend and its options. The select-all box is only there when you ask for one, and its options then sit in from it, so they read as its children. The hint and the error message sit outside the group, in the page, and its <fieldset> points to them by their id. The parts always render in the same order, which you can see in the DOM contract.
Sizes
The size prop sets the size of every checkbox in the group, and of its legend, and md is the default. The names are the same as the Checkbox's, so a group reads at the same size as the inputs and buttons next to it.
<CheckboxGroup size="xs" name="emails" options={emails}>Which emails would you like?</CheckboxGroup>
{/* …sm, md, lg, xl */}
| Size | Legend | Options' text | Indent under select all |
|---|---|---|---|
xs | 12px | 12px | 20px |
sm | 14px | 14px | 24px |
md | 14px | 14px | 26px |
lg | 16px | 16px | 28px |
xl | 16px | 16px | 34px |
Sizes are set in rem, and the table shows the values we recommend at the default text size of 16px. The legend is the same size as its options' text, in a heavier weight, so it reads as their heading without shouting over the rest of the form. The indent is the select-all box's width plus its gap, so each option's box lines up with the select-all box's label. Each checkbox keeps its 24 × 24px hit area at every size (see the Checkbox's Sizes).
Orientation
Options run in a column by default, one per line, and orientation="horizontal" puts them in a row, which wraps onto the next line when it runs out of room.
<CheckboxGroup name="emails" options={emails}>Which emails would you like?</CheckboxGroup>
<CheckboxGroup name="contact" options={contact} orientation="horizontal">How can we reach you?</CheckboxGroup>
We recommend the column for almost every group, which is why it's the default. Every label starts at the same edge, so people can scan down the list without hunting for where the next option begins, and every box sits right next to its own label, so nobody wonders whether a box belongs to the label on its left or on its right. A long label wraps under itself rather than pushing the next option onto another line, and the column still works on a narrow phone and at 200% zoom (WCAG 1.4.10). Keep the row for two or three options with short labels, like "Email", "Phone" and "Post".
States
Each checkbox shows its own states, as on the Checkbox page. On top of those, the group can be disabled or invalid as a whole, and a select-all box shows whether all, some or none of its options are checked.
Pick at least one email to sign up for
<CheckboxGroup name="emails" options={emails} defaultValue={["news"]}>Which emails would you like?</CheckboxGroup>
<CheckboxGroup name="emails" options={emails} defaultValue={["news"]} selectAll="All emails">
Which emails would you like?
</CheckboxGroup>
<CheckboxGroup name="emails" options={emails} defaultValue={["news"]} disabled>Which emails would you like?</CheckboxGroup>
<CheckboxGroup name="emails" options={emails} aria-invalid="true" aria-describedby="emails-error">
Which emails would you like?
</CheckboxGroup>
<p id="emails-error">Pick at least one email to sign up for</p>
| State | Selector | Recommended look |
|---|---|---|
| Disabled | :disabled, which a <fieldset disabled> matches | The legend at 50% opacity, and every checkbox looks disabled |
| Invalid | [aria-invalid="true"], which each option gets too | Each option's box with the Checkbox's invalid border |
| Select all | Its <input>'s :checked and :indeterminate | The Checkbox's check, or its dash while only some options are checked |
The group draws no state of its own on the boxes. A disabled <fieldset> disables every checkbox inside it, and each one looks disabled from :disabled, as it would on its own. An invalid group passes aria-invalid on to its options, so each box shows the Checkbox's invalid border, and the error message says what's wrong in words, since a red border alone can't (WCAG 1.4.1). None of the states change the group's size or position. You'll find how each state behaves under Every state.
Tokens
The group itself reads one theme token, and each of its checkboxes reads the Checkbox's own five:
| Token | Used for |
|---|---|
--color--neutral-text | The legend, which needs 4.5:1 against the page, like any text |
The legend is drawn in the same colour as the checkboxes' labels, so it has the same contrast as they do: 4.5:1 or more against the page with our reference tokens (WCAG 1.4.3). You'll find the tokens themselves in the Button's tokens.css, which you can copy into your theme.
If you need to tune a group, you can set its own variables, such as --checkbox-group--row-gap or --checkbox-group--legend--font-size, from a class. Each box sets the Checkbox's variables on itself, so a value on the group doesn't reach them. Set those from the same class with a descendant selector, like .brand .checkbox, written so that your framework's style scoping lets it through to the boxes, which the group renders: :global() in CSS Modules, Svelte and Astro, :deep() in Vue and ::ng-deep in Angular, as below. The Checkbox Group's styles sit in the components cascade layer, so your class wins without any specificity tricks:
import styles from "./Emails.module.css";
export function Emails() {
return (
<CheckboxGroup name="emails" options={emails} className={styles.brand}>
Which emails would you like?
</CheckboxGroup>
);
}
.brand {
--checkbox-group--row-gap: 0.75rem;
}
/* The boxes' own class isn't this module's, so it stays global. */
.brand :global(.checkbox) {
--checkbox--box--radius: 0;
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
checkbox-group/legend-contrast | Must | Component | The legend has a contrast of 4.5:1 against the page | WCAG 1.4.3 (AA) | axe color-contrast, contrast checker |
checkbox-group/vertical-by-default | Should | Component | Options run in a column unless it's set to a row, and a row wraps rather than overflowing | Open Components | Visual regression test |
checkbox-group/option-spacing | Should | Component | Options sit at least 8px apart in a column and 24px apart in a row | Open Components | Visual regression test |
checkbox-group/select-all-indent | Should | Component | Options under a select-all box sit in from it by its box and gap, so their boxes line up with its label | Open Components | Visual regression test |
checkbox-group/resizable | Must | Component | The legend, the gaps and the indent are sized in rem, and the legend and a row of options wrap instead of being clipped or scrolling sideways | Open Components, beyond WCAG 1.4.4, 1.4.10, 1.4.12 (AA) | 200% zoom |
checkbox-group/disabled-look | Must | Component | A disabled group shows its legend as disabled along with its boxes, in GrayText in forced colours mode | Open Components | Visual regression test, emulation |
checkbox-group/stable-size | Must | Component | No state changes the group's size or position | Open Components | Visual regression test |
Overview
How to build a group of checkboxes that people can pick any number of options from, that's named by its question, that says when they need to pick one, and that agents can check.
User Experience
How a checkbox group behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.