Components

Checkbox Group

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.

Checkbox groups let people pick any number of options from a short list, like the emails they'd like to get, or the days they're free. Each box on its own is a Checkbox, and what goes wrong is usually what holds them together. Think of boxes that a screen reader announces one by one, without the question they answer, a "Select all" box that stays unchecked when every option is checked, or a form that says "This field is required" until you've checked every single box.

This page walks you through building one the right way. We'll look at:

  • User Interface (UI) - how a checkbox group looks
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to check and uncheck them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Checkbox Group with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Checkbox Group is a <fieldset> of Checkboxes, so every rule on the Checkbox page holds for each of its boxes, and this page covers what a group adds on top. Its hint and error message are elements of their own, which you link to it by ID, like the Input's.

Which emails would you like?
<CheckboxGroup name="emails" options={emails} value={picked} onValueChange={setPicked}>
  Which emails would you like?
</CheckboxGroup>

At a glance

AspectCheckbox Group
Element<fieldset>, with a <legend> and a Checkbox for each option
Rolegroup, holding a checkbox for each option
NameThe text of its <legend>
DescriptionIts hint and its error message, linked with aria-describedby
ValueA list of the checked options' values, sent with one shared name
KeyboardTab reaches each checkbox in turn and Space checks or unchecks it, with no arrow keys
StatesDisabled and invalid, along with each checkbox's own, and a select-all box that can be mixed
Sizesxs, sm, md, lg and xl, the same as the Checkbox's
Orientationvertical, the default, or horizontal for two or three short options
PatternWAI-ARIA APG Checkbox, with its mixed-state example, and the HTML <fieldset>
WCAG 2.21.3.1, 1.4.10, 2.1.1, 3.3.1, 3.3.2, 3.3.3 and 4.1.2, among others

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

  1. Group — The <fieldset>, which holds the legend and every checkbox, and carries the size and orientation.
  2. Legend — The <legend>, its first child, which names the group, usually with a question.
  3. Select all — Optional. A Checkbox that checks or clears every option, and shows whether all, some or none are checked.
  4. Options — A Checkbox for each option, which all share one name.
  5. Hint and error — Optional. Paragraphs of their own, after the group, which its <fieldset> points to by their id.

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.

Which emails would you like? (xs)
Which emails would you like? (sm)
Which emails would you like? (md)
Which emails would you like? (lg)
Which emails would you like? (xl)
<CheckboxGroup size="xs" name="emails" options={emails}>Which emails would you like?</CheckboxGroup>
{/* …sm, md, lg, xl */}
SizeLegendOptions' textIndent under select all
xs12px12px20px
sm14px14px24px
md14px14px26px
lg16px16px28px
xl16px16px34px

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.

Which emails would you like?
How can we reach you?
<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.

Which emails would you like?
Some options checked (try tabbing through them)
Which emails would you like?
With a select-all box, mixed
Which emails would you like?
Disabled
Which emails would you like?

Pick at least one email to sign up for

Invalid
<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>
StateSelectorRecommended look
Disabled:disabled, which a <fieldset disabled> matchesThe legend at 50% opacity, and every checkbox looks disabled
Invalid[aria-invalid="true"], which each option gets tooEach option's box with the Checkbox's invalid border
Select allIts <input>'s :checked and :indeterminateThe 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:

TokenUsed for
--color--neutral-textThe 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:

Emails.tsx
import styles from "./Emails.module.css";

export function Emails() {
  return (
    <CheckboxGroup name="emails" options={emails} className={styles.brand}>
      Which emails would you like?
    </CheckboxGroup>
  );
}
Emails.module.css
.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;
}

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.

Which emails would you like?

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-describedby on the group at its id, 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-describedby lists 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-label to checkbox/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

KeyCheckbox Group
Tab, Shift+TabMoves focus to the next or previous checkbox, select-all box included
SpaceChecks or unchecks the focused checkbox
Arrow keysDon'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.

Which emails would you like?

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-mixed asks.
  • 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.

Which emails would you like?

Pick at least one.

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 required to 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's aria-describedby, after the hint (WCAG 3.3.1). The Checkbox Group passes aria-invalid on 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 novalidate on 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).

Developer Experience (DX)

Next, let's look at how developers use the Checkbox Group. It has one API, with the same names as the Checkbox and every other component, it's typed end to end, and it works with your forms however you build them.

import { useState } from "react";
import { CheckboxGroup } from "@/components/CheckboxGroup";

const emails = [
  { value: "news", label: "Product news" },
  { value: "tips", label: "Tips and guides" },
  { value: "events", label: "Events near you" },
];

export function Emails() {
  const [picked, setPicked] = useState<string[]>([]);

  return (
    <CheckboxGroup name="emails" options={emails} value={picked} onValueChange={setPicked}>
      Which emails would you like?
    </CheckboxGroup>
  );
}

Consistent

Once you've learned the Checkbox's API, you already know most of the Checkbox Group's. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
sizePropxs, sm, md, lg or xl, with the same text everywhere
disabledPropThe component can't be used, and here, nothing inside it either
namePropThe name its form sends it under, which every one of its boxes shares
valuePropThe checked options' values, which you bind the way your framework binds a form control's
optionsPropWhat people can pick from, each with a value and a label
orientationPropvertical or horizontal, the way its options run
selectAllPropThe label of a box that checks or clears every option
Default slotSlotThe legend, which names it, as the label names a Checkbox

We recommend size="md" and orientation="vertical" as the defaults. The Checkbox Group also leaves a few things out, on purpose:

  • There are no Checkboxes between its tags. The options are a prop, so the group can render a Checkbox for each one, give them all its name, bind each one to its value and work out the select-all box for you, and a list of options from your data drops straight in.
  • There's no required. HTML has no way to require one of a group of checkboxes, so the group leaves it to your code, as under Pick at least one.
  • There's no hint or error. Each of them is an element of its own, linked by ID, as on the Checkbox and the Input, so the group fits any layout.
  • There's no color or variant, since its boxes have none either.

Type-safe

Props, events and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like orientation="row", which isn't one of the orientations, or a value that isn't a list.

PropTypeDefaultDescription
optionsCheckboxGroupOption[]One Checkbox for each, in this order
namestringThe name every box shares, which its form sends
valuestring[][]The checked options' values, when you bind it. Without it, the group keeps its own
size"xs" | "sm" | "md" | "lg" | "xl""md"How big its boxes, their labels and its legend are
orientation"vertical" | "horizontal""vertical"Whether its options run in a column or in a row
disabledbooleanfalseDisables every box in it, with <fieldset disabled>
selectAllstringThe label of a select-all box above the options. Without it, there's none
Option fieldTypeDescription
valuestringWhat the form sends while it's checked, and what the value lists
labelstringThe text that names its Checkbox
EventPayloadDescription
changeEventFires when people check or uncheck any of its boxes, select-all box included

It comes from the box that changed, and bubbles up to the <fieldset>, so one listener hears every box, and event.target is the one that changed. A binding to value follows the group for you, with the new list of values.

SlotDescription
DefaultThe legend, which names the group

We also export CheckboxGroupProps and CheckboxGroupOption, which come in handy for wrappers and options from your data:

import type { CheckboxGroupOption } from "@/components/CheckboxGroup";

const emails: CheckboxGroupOption[] = topics.map((topic) => ({ value: topic.id, label: topic.title }));

Composable

The Checkbox Group renders a <fieldset>, and everything you pass it that isn't a prop lands there, including id, class, ARIA attributes and event listeners. A ref to the Checkbox Group reaches the <fieldset> too, which is what you need to focus its first box when a submit fails.

Forms

Every box shares the group's name, and each checked box sends it with its own value, so the form sends one field with a value for each checked option, in the options' order, and nothing at all while none is checked. Read it with getAll(), since get() only returns the first value. That also means the form works before your JavaScript loads.

import type { FormEvent } from "react";

export function Preferences() {
  function save(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    // One name, with a value for each checked option, so read them all.
    api.savePreferences({ emails: data.getAll("emails") });
  }

  return (
    <form onSubmit={save}>
      <CheckboxGroup name="emails" options={emails} defaultValue={["news"]}>Which emails would you like?</CheckboxGroup>
      <Button type="submit" color="primary">Save preferences</Button>
    </form>
  );
}

Keep in mind that a server reading the form sees emails=news&emails=tips, the same name once for each checked option, and no field at all when none is checked. Some frameworks keep only the last value of a repeated name unless you ask for all of them, like PHP, which needs the name to end in [], so check how yours reads it.

With other components

  • Each option is a Checkbox, which sets its own variables on itself. To restyle every box in a group, set them from a class on the group with a descendant selector, like .brand .checkbox { --checkbox--box--fill: … }, that gets past your framework's style scoping, as under Tokens. A value on the group itself doesn't reach them.
  • The Field, on our Roadmap, will render a group's hint and error message, and set its aria-describedby and aria-invalid, as it will for the Checkbox and the Input.
  • A Button that submits the form goes after the group, and takes the same size, so their text lines up.

Controllable

The Checkbox Group doesn't keep any state you can't reach. Its value is yours to bind, or to leave to the group, and every other state is a prop or an attribute that you control:

Which emails would you like?

You picked: news

How can we reach you?
const [picked, setPicked] = useState(["news"]);

<CheckboxGroup name="emails" options={emails} value={picked} onValueChange={setPicked}>Which emails would you like?</CheckboxGroup>
<p>You picked: {picked.join(", ") || "nothing yet"}</p>

<CheckboxGroup name="contact" options={contact} defaultValue={["email"]}>How can we reach you?</CheckboxGroup>
StateHow you control it
Valuevalue, bound to your state, or left to the group
Select allselectAll, with its checked and mixed states worked out from the value
Disableddisabled, or a <fieldset disabled> around it
Invalidaria-invalid, set while its error message shows (see Pick at least one)
FocusA ref to its <fieldset>, and focus() on its first box

When you bind value, the boxes always show the list you hold. If a change doesn't make it into your state, like an option people aren't allowed to drop, the box goes back as soon as it renders, and so does the select-all box. When you don't bind it, the group keeps its own value, and starts out from the one you give it.

Agentic Experience (AX)

Finally, let's look at how agents read, check and verify a checkbox group. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against.

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names and states. This is how they see the select-all example on this page:

- group "Which emails would you like?":
  - checkbox "All emails" [checked=mixed]
  - checkbox "Product news" [checked]
  - checkbox "Tips and guides"
  - checkbox "Events near you"

A heading or a paragraph above some boxes is only text, so an agent can't tell which boxes belong to which question, any more than a screen reader user can. With a named group, an agent can find the question, pick from it and confirm what it picked:

const emails = page.getByRole("group", { name: "Which emails would you like?" });
await emails.getByRole("checkbox", { name: "All emails" }).check();
for (const box of await emails.getByRole("checkbox").all()) await expect(box).toBeChecked();

Described

Here's the whole contract in one block, for agents to read before they build or review a checkbox group:

component: CheckboxGroup
summary: Lets people pick any number of options from a short list. Its legend names it, and each option is a Checkbox.
standard: https://opencomponents.dev/docs/components/checkbox-group
element: fieldset # its first child is the legend that names it. It takes every attribute that isn't a prop
role: group
props:
  options: { type: "{ value: string, label: string }[]" } # a Checkbox for each, in this order
  name: { type: string } # shared by every box, so the form sends one field with a value per checked option
  value: { type: "string[]", default: [] } # the checked options' values, in their order, when it's bound. Without it, the group keeps its own
  size: { type: [xs, sm, md, lg, xl], default: md }
  orientation: { type: [vertical, horizontal], default: vertical }
  disabled: { type: boolean, default: false } # sets disabled on the fieldset
  selectAll: { type: string } # the label of a box above the options that checks or clears them all
slots:
  default: The legend, which names the group.
events:
  change: Event, from the box that changed, which bubbles up to the fieldset.
states:
  disabled: ":disabled, from <fieldset disabled>, which every box inside it matches too"
  invalid: "[aria-invalid=true], which each option gets too, never :invalid"
  select-all: "> [data-slot=select-all] input:checked when every option is, :indeterminate when only some are"
keyboard:
  Tab: Moves focus to the next box, select-all box included. There's no roving focus.
  Space: Checks or unchecks the focused box. On the select-all box, it checks every option, or clears them all.
parts: [legend, select-all, options] # data-slot, in this order. Only select-all is optional
tokens:
  theme: ["--color--neutral-text"] # and the Checkbox's, through its boxes
  variables: [--checkbox-group--legend--font-size, --checkbox-group--legend--margin-bottom, --checkbox-group--indent, --checkbox-group--row-gap, --checkbox-group--column-gap]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/checkbox-group#checklist

It's also published on its own at /raw/docs/components/checkbox-group.yaml, with every rule from the checklist in place of the rules link. Its boxes follow the Checkbox's contract too, so give agents both.

Every rule has a stable ID, like checkbox-group/select-all-state, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's a group with a select-all box and two options, "Product news" (news) and "Tips and guides" (tips), described by its hint:

<CheckboxGroup name="emails" selectAll="All emails" aria-describedby="emails-hint">
  Which emails would you like?
</CheckboxGroup>

And here's what it renders:

<fieldset class="checkbox-group" aria-describedby="emails-hint" data-size="md" data-orientation="vertical">
  <legend data-slot="legend">Which emails would you like?</legend>
  <div data-slot="select-all">
    <label class="checkbox" data-size="md">
      <input data-slot="box" type="checkbox" />
      <svg data-slot="indicator" viewBox="0 0 16 16" aria-hidden="true">
        <path data-mark="check" d="M3.5 8.5 6.5 11.5 12.5 4.5" />
        <path data-mark="dash" d="M4 8h8" />
      </svg>
      <span data-slot="label">All emails</span>
    </label>
  </div>
  <div data-slot="options">
    <label class="checkbox" data-size="md">
      <input data-slot="box" type="checkbox" name="emails" value="news" />
      <svg data-slot="indicator" viewBox="0 0 16 16" aria-hidden="true">…</svg>
      <span data-slot="label">Product news</span>
    </label>
    <label class="checkbox" data-size="md">
      <input data-slot="box" type="checkbox" name="emails" value="tips" />
      <svg data-slot="indicator" viewBox="0 0 16 16" aria-hidden="true">…</svg>
      <span data-slot="label">Tips and guides</span>
    </label>
  </div>
</fieldset>
  • The size and the orientation are mirrored as data-size and data-orientation, which are always there, and every box takes the group's data-size.
  • Parts are marked with data-slot and always come in the same order: legend, select-all and options. Only the select-all box is optional, and each box inside follows the Checkbox's DOM contract.
  • States change properties and attributes, never elements. The select-all box's mixed state is a property of its <input>, so it never shows up in the markup, and no box is ever replaced as people check and uncheck them, so focus and the screen reader's position survive every change.
  • There's nothing random or generated, like IDs or timestamps, so the markup is the same on the server, on the client and in every snapshot. The legend names the <fieldset> without an ID. The select-all box has no aria-controls, which the APG's example gives it. We leave it out on purpose, since it would need an ID on every option, and the <fieldset> already ties the box to the options inside it.

Verifiable

Every rule in the checklist can be checked, and about two thirds of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in CheckboxGroup.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them.

In the browser

In the browser, you can run axe to check labels and contrast, and use the keyboard and the select-all box the way an agent would:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("the emails group is named, reached one box at a time, and its select-all box checks them all", async ({ page }) => {
  await page.goto("/preferences");

  const emails = page.getByRole("group", { name: "Which emails would you like?" });
  const all = emails.getByRole("checkbox", { name: "All emails" });
  await all.focus();
  await page.keyboard.press("Tab");
  await expect(emails.getByRole("checkbox", { name: "Product news" })).toBeFocused();
  await page.keyboard.press("Space");
  await expect(all).toBeChecked({ indeterminate: true });
  await all.check();
  for (const box of await emails.getByRole("checkbox").all()) await expect(box).toBeChecked();

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);
});

Some things still need a human, so try your groups with the keyboard alone, with a screen reader, which should announce the legend as you move into the group and the select-all box as mixed, and at 200% zoom, with forced colours and more contrast turned on. You can emulate the last two in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as a group renders without a legend, which leaves screen reader users with a list of boxes and no question.

Prompts

You can point your agent straight at this page. To build a checkbox group, you could use:

Build a Checkbox Group component for our React design system that meets the Open Components
Checkbox Group standard: https://opencomponents.dev/raw/docs/components/checkbox-group.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass.
Then check your work against every rule in its checklist with a Component or Both
scope, and cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Checkbox Group against the Open Components Checkbox Group contract:
https://opencomponents.dev/raw/docs/components/checkbox-group.yaml

For each rule with a component or both scope, report pass or fail with the rule ID
and the evidence from our code. Then fix the failures.

And to review how a screen uses its checkbox groups:

Review how our preferences page uses its checkbox groups against the Open Components
Checkbox Group contract: https://opencomponents.dev/raw/docs/components/checkbox-group.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A checkbox group meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it. Each of its boxes is a Checkbox, so the Checkbox's checklist applies to every one of them as well, and the rules below only cover what the group adds.

The scope says who meets a rule. Component rules are met by the Checkbox Group itself, so they're the ones to check when you build or review one. Usage rules are met by the forms that use it, like writing a legend that asks the question, so they're the ones to check when you review a screen. Both rules need the two to work together, like an invalid group: the Checkbox Group marks its boxes invalid from aria-invalid, as long as you set it when the error message shows.

The basis says where a rule comes from. Many rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from HTML or the APG. The rest are Open Components rules, which are our own, like stacking options in a column. Where a rule goes further than WCAG asks at level AA, its basis says so with beyond.

UI rules

RuleLevelScopeRequirementBasisCheck
checkbox-group/legend-contrastMustComponentThe legend has a contrast of 4.5:1 against the pageWCAG 1.4.3 (AA)axe color-contrast, contrast checker
checkbox-group/vertical-by-defaultShouldComponentOptions run in a column unless it's set to a row, and a row wraps rather than overflowingOpen ComponentsVisual regression test
checkbox-group/option-spacingShouldComponentOptions sit at least 8px apart in a column and 24px apart in a rowOpen ComponentsVisual regression test
checkbox-group/select-all-indentShouldComponentOptions under a select-all box sit in from it by its box and gap, so their boxes line up with its labelOpen ComponentsVisual regression test
checkbox-group/resizableMustComponentThe 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 sidewaysOpen Components, beyond WCAG 1.4.4, 1.4.10, 1.4.12 (AA)200% zoom
checkbox-group/disabled-lookMustComponentA disabled group shows its legend as disabled along with its boxes, in GrayText in forced colours modeOpen ComponentsVisual regression test, emulation
checkbox-group/stable-sizeMustComponentNo state changes the group's size or positionOpen ComponentsVisual regression test

UX rules

RuleLevelScopeRequirementBasisCheck
checkbox-group/fieldsetMustComponentIt's a <fieldset> whose first child is the <legend> that names it, so it has the group role and the legend as its nameWCAG 1.3.1, 4.1.2 (A), HTMLUnit test
checkbox-group/legend-questionMustUsageEvery group has a visible legend that says what its options are for, ideally as the question they answerWCAG 1.3.1, 3.3.2 (A)Review
checkbox-group/describedShouldBothA hint and an error message sit outside the legend and are linked to the <fieldset> with aria-describedby, the hint's ID firstWCAG 1.3.1 (A), APGUnit test, screen reader
checkbox-group/keyboardMustComponentTab reaches each box in turn, select-all box included, Space toggles the focused one, and the arrow keys never move focus between themWCAG 2.1.1 (A), APGUnit test
checkbox-group/select-all-stateMustComponentThe 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 changeWCAG 4.1.2 (A), APGUnit test
checkbox-group/select-all-actionMustComponentChecking the select-all box, from unchecked or mixed, checks every option, and unchecking it clears them all, without going back to an earlier selectionAPG, Open ComponentsUnit test
checkbox-group/select-all-labelShouldUsageA select-all box says what it selects, like "All emails", and only comes with lists that are long or often picked in fullOpen ComponentsReview
checkbox-group/verticalShouldUsageOptions run in a column, and only two or three short ones run in a rowOpen ComponentsReview
checkbox-group/predictable-orderShouldUsageOptions come in an order people can predict, like most to least common or alphabetical, and keep it every timeOpen ComponentsReview
checkbox-group/at-least-oneMustUsageA 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 boxesHTML, WCAG 3.3.2 (A)Review
checkbox-group/error-messageMustUsageAn invalid group has an error message, in text and linked to the <fieldset> with aria-describedby, that says what to pickWCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA)Screen reader
checkbox-group/invalid-stateMustBothThe group sets aria-invalid="true" while its error message shows, and each of its options looks and reads invalid with itWCAG 4.1.2 (A), Open ComponentsUnit test
checkbox-group/focus-on-errorShouldUsageA failed submit moves focus to the group's first box, once the error is in the pageOpen Components, beyond WCAG 3.3.1 (A)Keyboard
checkbox-group/disabled-inertMustComponentA disabled group sets disabled on its <fieldset>, so no box in it, select-all box included, can be focused, toggled or sentHTMLUnit test
checkbox-group/mirrors-in-rtlShouldComponentThe indent and a row of options follow dir, with logical propertiesOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
checkbox-group/shared-vocabularyMustComponentIt uses the shared names: size, disabled, name, value, options, orientation, selectAll, the default slot for its legend and changeOpen ComponentsReview
checkbox-group/typedMustComponentProps, events and slots are typed, with unions rather than string, and the value is a list of stringsOpen ComponentsType check
checkbox-group/composes-checkboxMustComponentEach option and the select-all box is a Checkbox, so every Checkbox rule holds for each of themOpen ComponentsReview
checkbox-group/form-controlMustComponentEvery option shares one name, and its form sends a value for each checked option, in the options' order, and nothing for the select-all boxHTMLUnit test
checkbox-group/controllableShouldComponentvalue is a list that can be bound or left to the group, and a bound group always shows the value you holdOpen ComponentsUnit test
checkbox-group/fieldset-attributesMustComponentAttributes, listeners and refs land on the <fieldset>, and each box's change bubbles up to itOpen ComponentsUnit test

AX rules

RuleLevelScopeRequirementBasisCheck
checkbox-group/role-and-nameMustComponentIt can be found by its legend alone, with getByRole("group", { name }), and each option inside it with getByRole("checkbox", { name })WCAG 4.1.2 (A)Unit test
checkbox-group/deterministic-domMustComponentThe same props render the same markup, with no generated IDs, and states change properties and attributes, never elementsOpen ComponentsUnit test
checkbox-group/data-attributesShouldComponentThe size and the orientation are mirrored as data-size and data-orientation, and parts are marked with data-slotOpen ComponentsUnit test
checkbox-group/dev-warningsShouldComponentIt warns during development when a group has no legendOpen ComponentsUnit test

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It's built out of the Checkbox, which it imports from ../checkbox/Checkbox.vue, so copy Checkbox.vue too. Both read the same theme tokens as the Button, so copy tokens.css from the Button's reference implementation into your theme as well.

The tests are in CheckboxGroup.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script setup lang="ts">
import { computed, onMounted, useAttrs, useTemplateRef } from "vue";
import Checkbox from "../checkbox/Checkbox.vue";

export interface CheckboxGroupOption {
  /** What the form sends for it while it's checked, and what the group's value lists. */
  value: string;
  /** The text that names its checkbox. */
  label: string;
}

export interface CheckboxGroupProps {
  /** One checkbox per option, in this order. */
  options: CheckboxGroupOption[];
  /** The name every checkbox shares, so the form sends the group as one field with a value per checked option. */
  name?: string;
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  /** A column by default, which is quicker to scan. A row only suits two or three short options. */
  orientation?: "vertical" | "horizontal";
  /** Disables every checkbox in it, with `<fieldset disabled>`. */
  disabled?: boolean;
  /** The label of a box above the options that checks or clears them all, and shows whether all, some or none are checked. */
  selectAll?: string;
}

// Attributes go on the <fieldset> through `attrs`, which also tells the options below when the group is invalid.
defineOptions({ inheritAttrs: false });

const { options, name, size = "md", orientation = "vertical", disabled = false, selectAll } = defineProps<CheckboxGroupProps>();

/** The checked options' values, in the options' order, which `v-model:value` binds. Without it, the group keeps its own, and starts from `value`. */
const value = defineModel<string[]>("value", { default: () => [] });

defineSlots<{
  /** The legend, which names the group. */
  default?: () => unknown;
}>();

const attrs = useAttrs();
// While the group is invalid, so is each of its options, so they look and read
// invalid too. The error message stays on the group, where it's described.
const invalid = () => attrs["aria-invalid"] === true || attrs["aria-invalid"] === "true";

const all = computed(() => options.length > 0 && options.every((option) => value.value.includes(option.value)));
// Worked out from the options every time they change, so it can't disagree with them.
const some = computed(() => !all.value && options.some((option) => value.value.includes(option.value)));

function toggle(option: CheckboxGroupOption, checked: boolean) {
  value.value = options
    .map(({ value }) => value)
    .filter((candidate) => (candidate === option.value ? checked : value.value.includes(candidate)));
}

// Checking the box, from unchecked or mixed, checks every option, and unchecking it
// clears them all, as the APG asks. It never goes back to an earlier selection, so a
// press always does what the box shows it will.
function toggleAll(checked: boolean) {
  value.value = checked ? options.map(({ value }) => value) : [];
}

const fieldset = useTemplateRef<HTMLFieldSetElement>("fieldset");

// A ref to the CheckboxGroup gets you the <fieldset>, through `fieldset`.
defineExpose({ fieldset });

onMounted(() => {
  if (import.meta.env.DEV) {
    const el = fieldset.value;
    const named =
      el?.querySelector(":scope > legend")?.textContent?.trim() ||
      el?.hasAttribute("aria-label") ||
      el?.hasAttribute("aria-labelledby");
    if (el && !named) {
      console.warn("[CheckboxGroup] has no accessible name: give it a legend that says what its options are for.", el);
    }
  }
});
</script>

<template>
  <fieldset
    ref="fieldset"
    class="checkbox-group"
    v-bind="attrs"
    :data-size="size"
    :data-orientation="orientation"
    :disabled="disabled"
  >
    <!-- The fieldset's first child, which names it. -->
    <legend data-slot="legend"><slot /></legend>
    <div v-if="selectAll" data-slot="select-all">
      <!-- No name, so the form never sends it: it stands for the options, which it does send.
           Its mixed state is bound, and its own update ignored, so it always shows what they add up to. -->
      <Checkbox
        :size="size"
        :checked="all"
        :indeterminate="some"
        @update:checked="toggleAll"
        @update:indeterminate="() => {}"
      >
        {{ selectAll }}
      </Checkbox>
    </div>
    <div data-slot="options">
      <Checkbox
        v-for="option in options"
        :key="option.value"
        :size="size"
        :name="name"
        :value="option.value"
        :checked="value.includes(option.value)"
        :aria-invalid="invalid() ? 'true' : undefined"
        @update:checked="(checked: boolean) => toggle(option, checked)"
      >
        {{ option.label }}
      </Checkbox>
    </div>
  </fieldset>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The group's styles go in `components`, so
   they win over the resets in `base`, and give way to utilities and to any style
   outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

@layer components {
  .checkbox-group {
    /* Set per size, along with the Checkbox's own. */
    --checkbox-group--legend--font-size: 0.875rem;
    /* How far the options sit in from a select-all box: its box and gap, so their boxes line up with its label. */
    --checkbox-group--indent: 1.625rem;
    /* 8px between stacked checkboxes, so a tap between two never toggles the wrong one. */
    --checkbox-group--row-gap: 0.5rem;
    --checkbox-group--column-gap: 1.5rem;
    --checkbox-group--legend--margin-bottom: 0.5rem;

    display: flex;
    flex-direction: column;
    gap: var(--checkbox-group--row-gap);
    min-inline-size: 0;
    margin: 0;
    padding: 0;
    border: 0;
    color: var(--color--neutral-text);
  }

  /* Sizes */
  .checkbox-group[data-size="xs"] {
    --checkbox-group--legend--font-size: 0.75rem;
    --checkbox-group--indent: 1.25rem;
  }
  .checkbox-group[data-size="sm"] {
    --checkbox-group--indent: 1.5rem;
  }
  .checkbox-group[data-size="lg"] {
    --checkbox-group--legend--font-size: 1rem;
    --checkbox-group--indent: 1.75rem;
  }
  .checkbox-group[data-size="xl"] {
    --checkbox-group--legend--font-size: 1rem;
    --checkbox-group--indent: 2.125rem;
  }

  /* Parts */
  .checkbox-group > [data-slot="legend"] {
    /* A rendered legend sits outside the flex box the fieldset lays its content out in, so it gets a margin rather than the gap. */
    margin-bottom: var(--checkbox-group--legend--margin-bottom);
    padding: 0;
    font-size: var(--checkbox-group--legend--font-size);
    font-weight: 600;
    line-height: 1.25;
  }
  .checkbox-group > [data-slot="options"] {
    display: flex;
    flex-direction: column;
    gap: var(--checkbox-group--row-gap) var(--checkbox-group--column-gap);
  }
  .checkbox-group > [data-slot="select-all"] + [data-slot="options"] {
    padding-inline-start: var(--checkbox-group--indent);
  }
  /* A row wraps when it runs out of room, rather than overflowing. */
  .checkbox-group[data-orientation="horizontal"] > [data-slot="options"] {
    flex-direction: row;
    flex-wrap: wrap;
  }

  /* States. :disabled matches a <fieldset disabled>, and every Checkbox inside it
     looks disabled on its own, since its look comes from :disabled too. */
  .checkbox-group:disabled > [data-slot="legend"] {
    opacity: 0.5;
  }

  @media (forced-colors: active) {
    .checkbox-group:disabled > [data-slot="legend"] {
      color: GrayText;
      opacity: 1;
    }
  }
}
</style>

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 fewCheckbox Group
Say yes or no to one thing, like agreeing to the termsCheckbox
Pick one option out of a fewRadio Group
Pick one option, or several, out of manySelect, or Combobox
Flip settings that take effect right away, like alertsA Switch for each
Select rows in a tableA Checkbox in each row, with an aria-label

The Radio Group, the Select and the Combobox are on our Roadmap.

Sources

Copyright © 2026