Components

Checkbox

How to build a checkbox that people can check with a click on its label, that says what it means and when it's mixed, that forms send the way they expect, and that agents can check.

Checkboxes let people say yes or no to one thing, like agreeing to the terms, or asking for a newsletter. The browser has had one built in for as long as there have been forms, but its box is hard to style, so many teams rebuild it, and that's where it goes wrong. Think of a box so small you keep missing it, a label that does nothing when you click it, or a "Select all" that screen readers announce as unchecked when half its options are checked.

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

  • User Interface (UI) - how a checkbox 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 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 is one box with its label. Its hint and error message are elements of their own, which you link to it by ID, like the Input's. A list of options that people check any number of is a group, with a <fieldset> and a <legend>, which the Checkbox Group builds out of Checkboxes.

<Checkbox checked={checked} onChange={(event) => setChecked(event.target.checked)}>
  I agree to the terms
</Checkbox>

At a glance

AspectCheckbox
Element<input type="checkbox">, inside the <label> that names it
Rolecheckbox
NameThe text inside its <label>, or an aria-label when there's no text to show
KeyboardTab to reach it and Space to check or uncheck it, while Enter never toggles it
StatesHover, focus, checked, indeterminate, disabled, read-only and invalid
Sizesxs, sm, md, lg and xl, with the box scaled to the text
PatternWAI-ARIA APG Checkbox, which a native <input type="checkbox"> covers
WCAG 2.21.3.1, 1.4.3, 1.4.11, 2.1.1, 2.4.7, 2.5.8, 3.3.1, 3.3.2 and 4.1.2, among others

User Interface (UI)

Let's start with how a checkbox looks: its parts, its sizes and how it shows each of its states.

Anatomy

  1. Container — The <label>, which wraps the box and its label, so a press on either toggles it. It carries the size.
  2. Box — The <input type="checkbox"> itself, which holds the state and takes focus.
  3. Indicator — A check, or a dash while it's indeterminate, drawn over the box and hidden from assistive technologies.
  4. Label — Optional. The text that says what checking the box means, which names it.
  5. Hit area — At least 24 × 24px around the box, which a press anywhere in toggles it.
  6. Focus ring — An outline around the box, drawn outside it so nothing moves.

Every checkbox has a container, a box, an indicator and a hit area. The label is optional only for a checkbox that its context already names, like one in each row of a table, which takes an aria-label instead (see Label every checkbox). Any hint and error message sit outside the container, in the page, and point to the box by its id. The parts always render in the same order, which you can see in the DOM contract.

Sizes

The size prop scales the box, the text and the gap between them together, and md is the default. The names are the same as the Button's and the Input's, and so is the text, so a checkbox reads at the same size as the inputs and buttons next to it.

<Checkbox size="xs">Email me updates</Checkbox>
{/* …sm, md, lg, xl */}
SizeBoxTextGapRadiusHit area
xs14px12px6px4px24px
sm16px14px8px4px24px
md18px14px8px4px24px
lg20px16px8px6px24px
xl24px16px10px6px24px

Sizes are set in rem, and the table shows the values we recommend at the default text size of 16px. The box grows with the text, a little larger than its letters, so it lines up with the first line of its label at every size. Whatever its size, the box sits in a hit area of at least 24 × 24px (WCAG 2.5.8), which is part of the <label>, so a press anywhere in it toggles the box. Each checkbox is at least as tall as its hit area, so two stacked checkboxes never share one.

States

Each state has a look of its own, styled from the <input> itself. That way, what people see and what assistive technologies report can never drift apart, and a box can't look checked without actually being checked.

Unchecked (try hovering, clicking or tabbing to it)
Checked
Indeterminate
Disabled
Read-only (try clicking or pressing Space)

Agree to the terms to create an account

Invalid
<Checkbox>Email me updates</Checkbox>
<Checkbox defaultChecked>Email me updates</Checkbox>
<Checkbox indeterminate>Select all</Checkbox>

<Checkbox disabled>Text me updates</Checkbox>
<Checkbox defaultChecked disabled>Email me updates</Checkbox>

<Checkbox readOnly>Text me updates</Checkbox>
<Checkbox defaultChecked readOnly>Email me updates</Checkbox>

<Checkbox required aria-invalid="true" aria-describedby="terms-error">I agree to the terms</Checkbox>
<p id="terms-error">Agree to the terms to create an account</p>
StateSelectorRecommended look
Hover:hover, inside @media (hover: hover)The border at 80% of the text colour, up from 55%
Focus> input:focus-visibleA 2px outline, 2px from the box, in --color--focus
Checked> input:checkedThe box filled with --color--primary, with a check on top
Indeterminate> input:indeterminateThe same fill, with a dash in place of the check
Disabled:has(> input:disabled)50% opacity and a not-allowed cursor, label included
Read-only> input[aria-readonly="true"]A dashed border, with the check in the text colour, no fill
Invalid> input[aria-invalid="true"]A 2px border in --color--error-text

The checked and indeterminate looks share their fill, and tell themselves apart by their mark, a check or a dash, rather than by colour (WCAG 1.4.1). The read-only look is dashed, like a read-only Input's, and drops the fill, so a checked box still shows its check without looking like one you can change. The disabled look comes from :disabled, which also matches every checkbox inside a <fieldset disabled>, so a whole group looks the way it behaves. We recommend leaving :invalid out of it, as on the Input, since it matches an unchecked required box before anyone has touched it. The invalid look comes from aria-invalid instead, which you set when the error message shows.

None of the states change the checkbox's size or position. The invalid border's second pixel is drawn inside the box, and the focus ring outside it. You'll find how each state behaves under Every state.

Tokens

The checkbox reads five theme tokens, which every component shares:

TokenUsed for
--color--neutral-textThe label, and mixed into the box's border
--color--primaryThe fill of a checked or indeterminate box
--color--primary-contrastThe check and the dash, on top of the fill
--color--error-textThe border of an invalid box
--color--focusThe focus ring, which is the same for every component

The border is the text colour at 55%, and 80% on hover. An unchecked box is nothing but its border, so the border needs 3:1 against the page, and so does the fill of a checked box, while the check needs 3:1 against the fill (WCAG 1.4.11). With our reference tokens, from the Tailwind CSS palette, the border comes to 3.5:1, the fill to 6.4:1 and the check to 6.4:1 on white, and 5.0:1, 5.7:1 and 6.4:1 on zinc-900. If you change the tokens, make sure to check the same pairs. 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 checkbox, you can set its own variables, such as --checkbox--box--radius, --checkbox--box--fill or --checkbox--indicator--color, from a class. The Checkbox's styles sit in the components cascade layer, so your class wins without any specificity tricks:

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

export function Terms() {
  return <Checkbox name="terms" className={styles.square}>I agree to the terms</Checkbox>;
}
Terms.module.css
.square {
  --checkbox--box--radius: 0;
}

User Experience (UX)

Now let's look at how a checkbox behaves for every person, input and setting. A good checkbox is accessible, predictable, designed in every state and adaptive.

Accessible

Use the native element

A native <input type="checkbox"> gives you focus, Space to toggle it, the checkbox role with its checked and mixed states, a label that toggles it, form submission and <fieldset disabled>, all for free. A <div> with role="checkbox" only gives you the role. Everything else is one more thing to rebuild by hand, and it's easy to miss one, like Space, which is why so many custom checkboxes only work with a mouse.

{/* Do: you get the keyboard, the label, the states and form submission for free */}
<label>
  <input type="checkbox" name="terms" />
  I agree to the terms
</label>

{/* Don't: there's no Space, label, form submission or disabled fieldset until you rebuild them */}
<div className="checkbox" role="checkbox" aria-checked={agreed} tabIndex={0} onClick={toggle}>
  I agree to the terms
</div>

The Checkbox styles the native box with appearance: none and draws its own border, fill and check, so it looks the same in every browser while it keeps everything the browser gives it.

Label every checkbox

The label is also the checkbox's accessible name. Screen readers read it along with the state, as in "I agree to the terms, checkbox, not checked", voice control users say it out loud ("click I agree to the terms"), and tests and agents use it to find the box.

About once a month. You can stop them at any time.

<Checkbox name="updates" aria-describedby="updates-hint">Email me product updates</Checkbox>
<p id="updates-hint">About once a month. You can stop them at any time.</p>
  • Every checkbox has a visible label right after its box (WCAG 1.3.1, 3.3.2). The Checkbox wraps its own <label> around the box and the text you give it, so the text names the box without an ID, and a click on the text toggles it, which makes for a much bigger target.
  • Keep links and buttons out of the label, like a link to the terms. Their text becomes part of the checkbox's name, and a press meant for the box can land on the link instead. Put the link in the hint, or right before the checkbox.
  • A hint, like how often you'll send those emails, goes in the checkbox's description, which screen readers read after its name and state. Keep it outside the Checkbox, so it doesn't become part of the name, and point aria-describedby at its id.
  • A checkbox that its context already names, like the one in each row of a table, is the one checkbox that doesn't need visible text. Give it an aria-label that says what it selects, like aria-label="Select Ada Lovelace", rather than one that repeats "Select" in every row.
  • If you name a checkbox with aria-label or aria-labelledby while it has visible text, make sure the name includes that text, ideally at the start (WCAG 2.5.3).

Contrast and size

  • The label needs a contrast of at least 4.5:1 with its background (WCAG 1.4.3). Disabled checkboxes are exempt, but they should still be readable.
  • The box's border needs 3:1 against the page, since an unchecked box is nothing but its border, and so does a checked box's fill, while its check or dash needs 3:1 against the fill (WCAG 1.4.11). The focus ring needs 3:1 as well.
  • The box sits in a hit area of at least 24 × 24px (WCAG 2.5.8). The label adds to that, since a press on it toggles the box too, but we don't count it, because a checkbox without visible text still needs a box that's easy to press. A smaller box meets WCAG through its spacing exception, as long as a 24px circle centred on it doesn't overlap another target, or another small target's circle. That's easy to break in a dense table, so the hit area is the safer bet.

Show focus

When a checkbox has focus, its box shows an outline in the same focus colour as every other component (WCAG 2.4.7). Like the Button's, it's at least 2px thick and drawn outside the border, and we recommend 2px for both. It goes around the box rather than the label, so it marks the thing Space will toggle, and it shows on :focus-visible, so it's there when people reach the box with the keyboard, and not after every click.

You'll also want to keep focused checkboxes clear of sticky headers and footers, which you can do with scroll-padding (WCAG 2.4.11, 2.4.12).

Predictable

Keyboard

KeyCheckbox
Tab, Shift+TabMoves focus to and from the box
SpaceChecks or unchecks it, and clears the indeterminate state, unless it's read-only
EnterNever toggles it

Space toggles a checkbox in every browser, which is what the APG asks for, and what keyboard users expect. Enter never does. Chrome, for example, submits the box's form instead, through its submit button, just as it would from a text input, which HTML calls implicit submission. So don't make Enter toggle the box, and don't rely on it to submit the form either, since that's up to the browser.

Say what checking it means

A checkbox says yes or no to one thing, so its label should say what yes means, as in "Email me product updates". People shouldn't have to work out what an unchecked box means, so try to keep labels positive. "Don't email me" makes people check a box to say no, and screen readers read it as "Don't email me, checkbox, checked", which takes a moment to untangle.

Checking a box also shouldn't do anything on its own. It's a choice that the form sends when people submit it, so they can change their mind until then. For a setting that takes effect the moment you flip it, like Wi-Fi, the Switch says so much better. And for a choice of one option out of a few, a Radio Group is the one to use, since checkboxes tell people they can check as many as they like.

Every state

Indeterminate

An indeterminate checkbox is neither checked nor unchecked, which is what a "Select all" box shows when only some of its options are checked. Screen readers announce it as "mixed", or "partially checked", and the box shows a dash in place of its check.

<Checkbox indeterminate>Select all</Checkbox>
  • Set it as the indeterminate property of the <input>. HTML has no attribute for it, so an indeterminate attribute in your markup does nothing, and the Checkbox sets the property for you, which is also what makes screen readers announce it as mixed.
  • It's a look, not a value. Under the dash, the box is still checked or unchecked, and its form sends whichever it is.
  • The browser clears it on the first click or Space, and checks or unchecks the box as usual, as you can try above. The Checkbox fires an update when it does, so a value you bind to it follows along.
  • Work it out from the options it stands for each time they change, rather than keeping it as state of its own, so it can never disagree with them. The Checkbox Group does that for its select-all box.

Disabled

A disabled checkbox can't be focused, toggled or submitted. disabled sets the native attribute, so the box leaves the tab order, a press on its label does nothing, screen readers announce it as dimmed or unavailable when they read through the page, and its form leaves it out when it's sent, even when it's checked. A <fieldset disabled> does all of that for every checkbox inside it, and the Checkbox looks disabled there too, since its look comes from :disabled.

We recommend disabling as little as you can, since a disabled checkbox can't explain why it's disabled, and keyboard users can't even reach it to find out. Explain why next to it, or leave the choice out until it applies.

Read-only

A read-only checkbox shows a choice that people can see but not change, like a setting that their organisation manages for them. Unlike a disabled one, it stays in the tab order, so people can reach it and hear its state, and its form still sends it. That makes it the one to use when people need to see the choice and the form needs to send it.

HTML gives you nothing to lean on here, since it ignores readonly on a checkbox, and :read-only matches every checkbox there is. So readonly on the Checkbox sets aria-readonly="true" on the box, which tells assistive technologies that it can't be changed, and cancels the click that would toggle it, whether it comes from the box, its label or Space. A cancelled click puts back both the checked and the indeterminate state, and fires no change, so nothing else on the page has to know.

Where the Checkbox renders on the server alone, a script cancels the click instead, and it's the same script for every read-only box on the page:

<Checkbox name="updates" defaultChecked readOnly>Email me updates</Checkbox>

We recommend saying why next to it, as for a disabled checkbox, like "Your organisation manages this setting". The dashed border tells people they can't change it, but not why, or who can.

Required and invalid

Most checkboxes are optional, since an unchecked box is an answer too. The exception is a box that people have to check to go on, like agreeing to the terms. Give it required, which screen readers announce, and when people submit without checking it, show an error message that says why it matters.

import { useRef, useState, type FormEvent } from "react";
import { flushSync } from "react-dom";

export function CreateAccount() {
  const [agreed, setAgreed] = useState(false);
  const [error, setError] = useState("");
  const [status, setStatus] = useState("");
  const terms = useRef<HTMLInputElement>(null);

  function update(checked: boolean) {
    setAgreed(checked);
    // Checking the box fixes the error, so it clears right away.
    if (checked) setError("");
  }

  async function createAccount(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!agreed) {
      // Focus once the error is in the page, so screen readers read it with the checkbox.
      flushSync(() => {
        setError("Agree to the terms to create an account");
        setStatus("Couldn't create your account. Check the terms.");
      });
      terms.current?.focus();
      return;
    }
    await api.createAccount();
    setStatus("Your account is ready");
  }

  return (
    <form noValidate onSubmit={createAccount}>
      <Checkbox
        ref={terms}
        name="terms"
        required
        checked={agreed}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? "terms-error" : undefined}
        onChange={(event) => update(event.target.checked)}
      >
        I agree to the terms
      </Checkbox>
      {error && <p id="terms-error">{error}</p>}
      <Button type="submit" color="primary">Create account</Button>
      <p role="status">{status}</p>
    </form>
  );
}
  • Set aria-invalid="true" on the box while its error message shows, and point aria-describedby at the message. Screen readers then announce the box as invalid, and read the message after its name (WCAG 3.3.1). If it has a hint too, list both IDs, the hint's first.
  • Say why the box needs checking, as in "Agree to the terms to create an account", rather than "This field is required" (WCAG 3.3.3). A red border alone can't say any of that, and it means nothing to people who can't tell red apart (WCAG 1.4.1).
  • Check it when people submit, rather than when they move past the box, since leaving a box unchecked is a perfectly good answer until then. Once the error shows, clear it as soon as they check the box.
  • When a submit fails, move focus to the first invalid control, once the error is in the page, as on the Input. The status message says what happened even when the focus doesn't move, like when people pressed Enter with the button already focused.
  • Put novalidate on the form, so that the browser's own error bubbles don't compete with yours. Keep required on the box, though, which tells screen readers that it's required.

Adaptive

Pointer, touch and pen

  • Hover styles only apply to pointers that can actually hover (@media (hover: hover)), otherwise a tap would leave the border looking hovered.
  • A press anywhere in the box's 24 × 24px hit area, or on its label, toggles it, unless it's read-only, since both are part of the <label>. The box and its check don't stop the press, so it reaches the <input> underneath.
  • Fingers are less precise than a mouse, so we recommend spacing stacked checkboxes 8px or more apart. That way, a tap that lands between two of them doesn't toggle the wrong one.

More contrast

When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the box's border is drawn in the text colour at full strength, rather than at 55%. The label, the fill and the check already pass, so they stay as they are.

Forced colours

Windows contrast themes replace every colour with the user's own, and drop backgrounds and shadows. The box's border stays, since the system draws it in a colour of its own, and so does focus, which is drawn with outline. A checked or indeterminate box is filled with Highlight, with its check or dash in HighlightText, a read-only box keeps its dashed border, with its check in CanvasText, and a disabled one is marked with GrayText. The invalid border's second pixel is a shadow, which forced colours drop, so in these themes it's the error message, in text, that tells people something's wrong.

Colour scheme

The tokens use light-dark(), which follows color-scheme, so the Checkbox adapts to your light and dark themes on its own.

Text size and spacing

The box, the gap and the hit area are set in rem, so the box grows with its text instead of looking tiny next to it, at 200% text size (WCAG 1.4.4) and with wider line, letter and word spacing (WCAG 1.4.12). A label that's longer than its column wraps, with the box beside its first line, rather than being clipped.

Languages and direction

The Checkbox lays out its box and label in a grid, which follows the reading direction, so the box sits at the start of the line in right-to-left languages too, with no extra work on your part:

<div dir="rtl" lang="ar">
  <Checkbox defaultChecked>أرسل لي التحديثات عبر البريد الإلكتروني</Checkbox>
  <Checkbox>أوافق على الشروط</Checkbox>
</div>
  • The check mark stays as it is in right-to-left layouts, as it does in native checkboxes, since it doesn't point anywhere.
  • Remember to translate labels, hints and error messages 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. It has one API, with the same names as every other component, it's typed end to end, and it works with your forms however you build them.

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

export function Updates() {
  const [updates, setUpdates] = useState(false);

  return (
    <Checkbox checked={updates} onChange={(event) => setUpdates(event.target.checked)}>
      Email me product updates
    </Checkbox>
  );
}

Consistent

Once you've learned the Button's and the Input's APIs, you already know most of the Checkbox'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
readonlyPropIt can be focused, and its form sends it, but it can't be changed
checkedPropWhether it's checked, which you bind the way your framework binds a checkbox's
indeterminatePropNeither checked nor unchecked, as the native property means it
Default slotSlotThe label, as it is for the Button

We recommend size="md" as the default. The Checkbox also leaves a few props out, on purpose:

  • There's no label prop. The label is the content between its tags, which can hold more than plain text, like a word in bold, and which the <label> wraps either way.
  • There's no color or variant. A checked box has no intent of its own, and its one other coloured state, invalid, comes from aria-invalid, so its colour and its state can't disagree. To match your brand, set --checkbox--box--fill from a class, as under Tokens.
  • There's no hint or error. Each of them is an element of its own, linked by ID, as on the Input, so the Checkbox fits any layout.
  • There's no options or array value. A list of options is a group, which the Checkbox Group builds out of Checkboxes, with a <fieldset> and a <legend>.

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 size="medium", which isn't one of the sizes.

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl""md"How big the box and its label are
checkedbooleanfalseWhether it's checked, when you bind it. Without it, the box keeps its own
indeterminatebooleanfalseShows a dash, and makes screen readers announce it as mixed
disabledbooleanfalseBlocks focus, toggling and submission
readonlybooleanfalseBlocks toggling, while it stays focusable and its form sends it
EventPayloadDescription
changeEventFires when people check or uncheck it, with a click, a tap or Space

It comes straight from the <input>, like every other native event, such as focus and blur, and a binding to checked listens to it for you. Read the new state from event.target.checked.

SlotDescription
DefaultThe label, which names the checkbox, and which a press on toggles the box, unless it's read-only

We also export CheckboxProps, which comes in handy for wrappers and data-driven forms:

import type { CheckboxProps } from "@/components/Checkbox";

const preferences: (CheckboxProps & { name: string; label: string })[] = [
  { name: "updates", label: "Email me product updates" },
  { name: "texts", label: "Text me about my orders", disabled: true },
];

Composable

The Checkbox renders a <label> around the <input>, so it splits what you pass between them. Classes and styles go on the <label>, so a class can set its variables. Everything else lands on the <input>, including id, name, value, required, ARIA attributes and event listeners. A ref to the Checkbox reaches the <input> too, since that's what form libraries and focus() need.

Forms

The <input> is a real form control, so the Checkbox works with your forms however you build them. You can bind checked, as above, or leave it to the browser and read it from the form when it's sent. A checked box sends its name with its value, or with on when it has no value, and an unchecked box sends nothing at all, so check whether its name is there, rather than for a value of false. 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);
    // An unchecked box sends nothing, so it's checked when its name is there.
    api.savePreferences({ updates: data.has("updates") });
  }

  return (
    <form onSubmit={save}>
      <Checkbox name="updates" value="yes" defaultChecked>Email me product updates</Checkbox>
      <Button type="submit" color="primary">Save preferences</Button>
    </form>
  );
}

Form libraries work the same way. They register the <input> itself, through its ref or its attributes and listeners, which is why the Checkbox puts them all on the <input>. Keep in mind that a server reading the form sees no field at all for an unchecked box, so treat a missing name as "no".

With other components

  • The Checkbox Group puts Checkboxes in a <fieldset> with a <legend>, gives them one name, binds each one's checked to a list of values, and works out its select-all box's indeterminate for you. A <fieldset disabled> already disables every Checkbox inside it.
  • The Field, on our Roadmap, will render a checkbox's hint and error message, and set its aria-describedby and aria-invalid, as it will for the Input.
  • A Button that submits the form goes after the checkboxes, and takes the same size, so their text lines up.

Controllable

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

You'll get our updates

const [updates, setUpdates] = useState(true);

<Checkbox checked={updates} onChange={(event) => setUpdates(event.target.checked)}>Email me updates</Checkbox>
<p>You'll {updates ? "get" : "miss"} our updates</p>

<Checkbox name="remember">Remember me</Checkbox>
StateHow you control it
Checkedchecked, bound to your state, or left to the browser
Indeterminateindeterminate, worked out from the options it stands for
Disableddisabled, or a <fieldset disabled> around it
Read-onlyreadonly, which sets aria-readonly on the <input> and cancels its clicks
Requiredrequired, which lands on the <input>
Invalidaria-invalid, set while its error message shows (see Required and invalid)
FocusA ref to its <input>, and focus()

When you bind checked, the box always shows the state you hold. If a change doesn't make it into your state, like an option people aren't allowed to uncheck, the box goes back to that state as soon as it renders, rather than showing a tick that your state doesn't have. When you don't bind it, the box keeps its own state, and starts out checked if you say so.

Agentic Experience (AX)

Finally, let's look at how agents read, check and verify a checkbox. 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 states example on this page:

- checkbox "Email me updates"
- checkbox "Email me updates" [checked]
- checkbox "Select all" [checked=mixed]
- checkbox "Text me updates" [disabled]
- checkbox "Email me updates" [checked] [disabled]
- checkbox "Text me updates"
- checkbox "Email me updates" [checked]
- checkbox "I agree to the terms" [invalid]

A <div> that only looks like a checkbox has no role and no state, so an agent can't tell whether it's checked, any more than a screen reader user can. With a role, a name and a state, an agent can find the box, check it and confirm that it took:

const terms = page.getByRole("checkbox", { name: "I agree to the terms" });
await terms.check();
await expect(terms).toBeChecked();

Described

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

component: Checkbox
summary: Says yes or no to one thing, like agreeing to the terms. The label it wraps names it.
standard: https://opencomponents.dev/docs/components/checkbox
element: input # type="checkbox", inside the label that names it, which takes class and style
role: checkbox
props:
  size: { type: [xs, sm, md, lg, xl], default: md }
  checked: { type: boolean, default: false } # when it's bound. Without it, the box keeps its own
  indeterminate: { type: boolean, default: false } # set as a property. The first click clears it, and a bound value with it
  disabled: { type: boolean, default: false }
  readonly: { type: boolean, default: false } # sets aria-readonly, since HTML ignores readonly on a checkbox, and cancels the click
slots:
  default: The label, which names the checkbox. A press on it toggles the box, unless it's read-only.
events:
  change: Event, from the <input>. Fires when people check or uncheck it.
states:
  hover: ":hover, in @media (hover: hover)"
  focus: "> input:focus-visible"
  checked: "> input:checked"
  indeterminate: "> input:indeterminate"
  disabled: ":has(> input:disabled), which a <fieldset disabled> sets too"
  read-only: "> input[aria-readonly=true], never :read-only"
  invalid: "> input[aria-invalid=true], never :invalid"
keyboard:
  Tab: Moves focus to the box.
  Space: Checks or unchecks it, and clears the indeterminate state, unless it's read-only.
  Enter: Never toggles it.
parts: [box, indicator, label] # data-slot, in this order. The box is the <input>
tokens:
  theme: ["--color--neutral-text", "--color--primary", "--color--primary-contrast", "--color--error-text", "--color--focus"]
  variables: [--checkbox--box--size, --checkbox--box--radius, --checkbox--box--border-color, --checkbox--box--fill, --checkbox--indicator--color, --checkbox--gap, --checkbox--font-size, --checkbox--hit-area--size]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/checkbox#checklist

It's also published on its own at /raw/docs/components/checkbox.yaml, with every rule from the checklist in place of the rules link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like checkbox/label-toggles, 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 required checkbox with an error:

<Checkbox name="terms" required aria-invalid="true" aria-describedby="terms-error">
  I agree to the terms
</Checkbox>

And here's what it renders:

<label class="checkbox" data-size="md">
  <input
    data-slot="box"
    type="checkbox"
    name="terms"
    required
    aria-invalid="true"
    aria-describedby="terms-error"
  />
  <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">I agree to the terms</span>
</label>
  • The size is mirrored as data-size, which is always there. The <input>'s type is always checkbox.
  • Parts are marked with data-slot and always come in the same order: box, indicator and label. Only the label is optional, and the indicator always holds both marks, which CSS shows one at a time.
  • States change properties and attributes, never elements. Indeterminate is a property of the <input> with no attribute, so it never shows up in the markup, and the <input> is never replaced, 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 <label> wraps the box, so it needs no ID to name it.

Verifiable

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

Unit tests

Every rule that the checklist checks with a unit test has one in Checkbox.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, contrast and target size, and use the keyboard the way an agent would, to check that Space toggles the box and Enter doesn't:

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

test("the terms checkbox toggles with Space and its label, but not with Enter", async ({ page }) => {
  await page.goto("/sign-up");

  const terms = page.getByRole("checkbox", { name: "I agree to the terms" });
  await terms.focus();
  await page.keyboard.press("Space");
  await expect(terms).toBeChecked();
  await page.getByText("I agree to the terms").click();
  await expect(terms).not.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 checkboxes with the keyboard alone, with a screen reader, which should announce each one's name and state, mixed included, 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 checkbox renders without a label or an aria-label, which leaves screen reader users with nothing but "checkbox, not checked".

Prompts

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

Build a Checkbox component for our React design system that meets the Open Components
Checkbox standard: https://opencomponents.dev/raw/docs/components/checkbox.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 against the Open Components Checkbox contract:
https://opencomponents.dev/raw/docs/components/checkbox.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 checkboxes:

Review how our sign-up form uses its checkboxes against the Open Components Checkbox
contract: https://opencomponents.dev/raw/docs/components/checkbox.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 meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Checkbox 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 label that says what checking the box means, so they're the ones to check when you review a screen. Both rules need the two to work together, like an invalid checkbox: the Checkbox looks invalid from aria-invalid, as long as you set it when the error message shows.

The basis says where a rule comes from. About half the 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, WAI-ARIA or the APG. The rest are Open Components rules, which are our own, like keeping a checkbox's label positive. Where a rule goes further than WCAG asks at level AA, its basis says so: a rule that asks for more than its criterion says beyond, like the 24 × 24px hit area around every box, where WCAG would also accept a smaller box with enough space around it.

UI rules

RuleLevelScopeRequirementBasisCheck
checkbox/contrastMustComponentThe label has a contrast of 4.5:1, and the focus ring has 3:1 against the pageWCAG 1.4.3, 1.4.11 (AA)axe color-contrast, contrast checker
checkbox/boundary-contrastMustComponentThe box's border and a checked box's fill have 3:1 against the page, and the check and the dash have 3:1 against the fillWCAG 1.4.11 (AA)Contrast checker
checkbox/focus-visibleMustComponentKeyboard focus shows an outline at least 2px thick around the box, outside its border, on :focus-visibleWCAG 2.4.7 (AA), 2.4.13 (AAA)Keyboard
checkbox/target-sizeMustComponentThe box has a hit area of at least 24 × 24px that toggles it, without counting its labelOpen Components, beyond WCAG 2.5.8 (AA)axe target-size, emulation
checkbox/distinct-marksMustComponentChecked shows a check and indeterminate a dash, so the two never rely on colour to tell them apartWCAG 1.4.1 (A)Visual regression test
checkbox/resizableMustComponentThe box, its gap and its hit area are sized in rem, and the label wraps beside the box instead of being clippedOpen Components, beyond WCAG 1.4.4, 1.4.12 (AA)200% zoom
checkbox/stable-sizeMustComponentNo state changes the checkbox's size or positionOpen ComponentsVisual regression test
checkbox/state-selectorsShouldComponentStates are styled from the <input>, with :checked, :indeterminate, [aria-readonly], :disabled and [aria-invalid], and never from :invalid or :read-onlyOpen ComponentsReview
checkbox/hover-capableShouldComponentHover styles only apply inside @media (hover: hover)Open ComponentsReview
checkbox/forced-colorsMustComponentThe box, its check or dash, and its focus, read-only and disabled states stay visible in forced colours modeOpen ComponentsEmulation
checkbox/more-contrastShouldComponentThe box's border is drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation

UX rules

RuleLevelScopeRequirementBasisCheck
checkbox/native-elementMustComponentIt's a native <input type="checkbox">, never an element with role="checkbox"Open Components, beyond WCAG 2.1.1, 4.1.2 (A)Unit test
checkbox/label-togglesMustComponentIts <label> wraps the box and its text, so the text names it and a press on the text toggles it, unless it's read-onlyWCAG 1.3.1, 4.1.2 (A), HTMLUnit test
checkbox/visible-labelMustUsageEvery checkbox has visible text, unless its context names it, like a row in a table, in which case it has an aria-labelWCAG 1.3.1, 3.3.2, 4.1.2 (A)axe label
checkbox/label-in-nameMustUsageAn aria-label or aria-labelledby includes the visible text, ideally at the startWCAG 2.5.3 (A)axe label-content-name-mismatch
checkbox/no-interactive-labelShouldUsageThe label holds no links or buttons, which go in the hint or before the checkboxOpen ComponentsReview
checkbox/positive-labelShouldUsageThe label says what checking the box means, in the positive, like "Email me updates"Open ComponentsReview
checkbox/describedShouldUsageHints sit outside the label and are linked with aria-describedbyWCAG 1.3.1 (A)Screen reader
checkbox/decorative-indicatorMustComponentThe check and the dash are hidden from assistive technologies with aria-hidden, so the name is the label aloneWCAG 1.1.1, 4.1.2 (A)Unit test
checkbox/keyboardMustComponentTab reaches the box, Space checks or unchecks it unless it's read-only, and Enter never toggles itWCAG 2.1.1 (A), APGUnit test
checkbox/indeterminateMustComponentThe mixed state is set as the <input>'s indeterminate property, so it's announced as mixed, and it clears on the first clickWCAG 4.1.2 (A), HTMLUnit test
checkbox/derived-mixedShouldUsageindeterminate is worked out from the options it stands for whenever they changeOpen ComponentsReview
checkbox/no-immediate-actionShouldUsageChecking a box changes nothing until its form is sent, and a setting that takes effect at once uses a SwitchOpen ComponentsReview
checkbox/requiredShouldBothA box people have to check has required, which lands on the <input>, while optional boxes don'tHTML, WCAG 3.3.2 (A)Unit test, review
checkbox/error-messageMustUsageAn invalid checkbox has an error message, in text and linked with aria-describedby, that says why it needs checkingWCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA)Screen reader
checkbox/invalid-stateMustBothA checkbox sets aria-invalid="true" while its error message shows, and only looks invalid when it doesWCAG 4.1.2 (A), Open ComponentsUnit test
checkbox/read-onlyMustComponentA read-only checkbox sets aria-readonly="true", stays focusable and is sent with its form, while a click, its label or Space never change itWAI-ARIA, Open ComponentsUnit test
checkbox/read-only-valuesShouldUsageA choice people need to see but can't change is read-only rather than disabled, and says why next to itOpen ComponentsReview
checkbox/disabled-inertMustComponentA disabled checkbox, or one inside a <fieldset disabled>, can't be focused, toggled or submitted, and looks disabledHTMLUnit test
checkbox/explain-disabledShouldUsageA disabled checkbox explains why next to it, or is left out until it appliesOpen ComponentsReview
checkbox/focus-not-obscuredMustUsageSticky headers and footers never cover a focused checkbox, for example thanks to scroll-paddingWCAG 2.4.11 (AA), 2.4.12 (AAA)Keyboard
checkbox/mirrors-in-rtlShouldComponentThe box sits at the start of the line in either direction, with logical properties and a grid that follows dirOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
checkbox/shared-vocabularyMustComponentIt uses the shared names: size, disabled, readonly, checked, indeterminate, the default slot for its label and changeOpen ComponentsReview
checkbox/typedMustComponentProps, events and slots are typed, with unions rather than stringOpen ComponentsType check
checkbox/control-attributesMustComponentAttributes, listeners and refs land on the <input>, while classes and styles land on the <label>Open ComponentsUnit test
checkbox/form-controlMustComponentIts form sends its name with its value while it's checked, and nothing while it's unchecked, whether checked is bound or notHTMLUnit test
checkbox/controllableShouldComponentchecked can be bound or left to the browser, a bound box always shows the state you hold, and every other state is a prop or an attributeOpen ComponentsUnit test

AX rules

RuleLevelScopeRequirementBasisCheck
checkbox/role-and-nameMustComponentIt can be found by its role and label alone, with getByRole("checkbox", { name }), and reports checked, unchecked or mixedWCAG 4.1.2 (A)Unit test
checkbox/deterministic-domMustComponentThe same props render the same markup, with no generated IDs, and states change properties and attributes, never elementsOpen ComponentsUnit test
checkbox/data-attributesShouldComponentThe size is mirrored as data-size, and parts are marked with data-slotOpen ComponentsUnit test
checkbox/dev-warningsShouldComponentIt warns during development when a checkbox has no label and no aria-labelOpen 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 reads the same theme tokens as the Button, so copy tokens.css from the Button's reference implementation into your theme too.

The tests are in Checkbox.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 { nextTick, onMounted, useAttrs, useTemplateRef, watchEffect } from "vue";

export interface CheckboxProps {
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  disabled?: boolean;
  /** Shows a state people can focus and send with the form, but not change. HTML ignores `readonly` on a checkbox, so it's `aria-readonly`, and a click is cancelled. */
  readonly?: boolean;
}

// Attributes go on the <input> rather than on the root, so they're declared below.
defineOptions({ inheritAttrs: false });

const { size = "md", disabled = false, readonly = false } = defineProps<CheckboxProps>();

/** Whether it's checked, which `v-model:checked` binds. Without it, the checkbox keeps its own, and starts from `checked`. */
const checked = defineModel<boolean>("checked", { default: false });
/**
 * Neither checked nor unchecked, like a select-all box when only some of its options
 * are. The browser clears it as soon as people click, which `v-model:indeterminate`
 * hears about.
 */
const indeterminate = defineModel<boolean>("indeterminate", { default: false });

defineSlots<{
  /** The label, which the <label> wraps along with the box, so it names the checkbox. */
  default?: () => unknown;
}>();

const attrs = useAttrs();
// Classes and styles go on the root, the <label>, so a class can set its variables.
// Everything else goes on the <input>: `id`, `name`, `value`, `required`, ARIA and
// listeners. Attrs aren't reactive, so these run on every render.
const onRoot = (key: string) => key === "class" || key === "style";
const rootAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => onRoot(key)));
const controlAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => !onRoot(key)));

const input = useTemplateRef<HTMLInputElement>("input");

// A cancelled click puts back the checked and indeterminate states, and fires no
// change, whether it came from the box, its label or Space.
function onClick(event: MouseEvent) {
  if (readonly) event.preventDefault();
}

// Bound before your own listeners, so a `change` listener of yours already sees the new state.
async function onChange(event: Event) {
  const box = event.target as HTMLInputElement;
  checked.value = box.checked;
  indeterminate.value = false;
  // A bound state that didn't take the change, like an option people can't uncheck,
  // wins once it has rendered, so the box always shows the state you hold.
  await nextTick();
  box.checked = checked.value;
  box.indeterminate = indeterminate.value;
}

// HTML has no attribute for indeterminate, so it's set on the <input> as a property,
// once it has rendered, which also keeps it out of the server's markup.
watchEffect(
  () => {
    if (input.value) input.value.indeterminate = indeterminate.value;
  },
  { flush: "post" },
);

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

onMounted(() => {
  if (import.meta.env.DEV) {
    const el = input.value;
    const named =
      [...(el?.labels ?? [])].some((label) => label.textContent?.trim()) ||
      el?.hasAttribute("aria-label") ||
      el?.hasAttribute("aria-labelledby");
    if (el && !named) {
      console.warn("[Checkbox] has no accessible name: give it a label, or an aria-label when it has none.", el);
    }
  }
});
</script>

<template>
  <label class="checkbox" v-bind="rootAttrs()" :data-size="size">
    <input
      ref="input"
      data-slot="box"
      type="checkbox"
      :disabled="disabled"
      :aria-readonly="readonly ? 'true' : undefined"
      @click="onClick"
      @change="onChange"
      v-bind="controlAttrs()"
      :checked="checked"
    />
    <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 v-if="$slots.default" data-slot="label"><slot /></span>
  </label>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The checkbox'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 {
    /* Set per size. The box grows with the text. */
    --checkbox--box--size: 1.125rem;
    --checkbox--box--radius: 0.25rem;
    /* The text colour at 55%, which has 3:1 against the page, so people can see the box. */
    --checkbox--box--border-color: color-mix(in srgb, currentColor 55%, transparent);
    --checkbox--box--fill: var(--color--primary);
    --checkbox--indicator--color: var(--color--primary-contrast);
    --checkbox--gap: 0.5rem;
    --checkbox--font-size: 0.875rem;
    /* The hit area around the box, whatever its size (WCAG 2.5.8). */
    --checkbox--hit-area--size: 1.5rem;

    position: relative;
    display: inline-grid;
    /* The box and its check share the first column, and the label takes a second. */
    grid-template-columns: var(--checkbox--box--size);
    column-gap: var(--checkbox--gap);
    align-items: start;
    color: var(--color--neutral-text);
    font-size: var(--checkbox--font-size);
    line-height: 1.25;
    cursor: pointer;
  }

  /* A press anywhere in the hit area toggles the box, since it's part of the <label>.
     Each checkbox is at least as tall as its hit area, so stacked ones never overlap. */
  .checkbox::before {
    content: "";
    position: absolute;
    top: max(0px, (var(--checkbox--box--size) - var(--checkbox--hit-area--size)) / 2);
    inset-inline-start: calc((var(--checkbox--box--size) - var(--checkbox--hit-area--size)) / 2);
    width: var(--checkbox--hit-area--size);
    height: var(--checkbox--hit-area--size);
  }

  /* Sizes */
  .checkbox[data-size="xs"] {
    --checkbox--box--size: 0.875rem;
    --checkbox--gap: 0.375rem;
    --checkbox--font-size: 0.75rem;
  }
  .checkbox[data-size="sm"] {
    --checkbox--box--size: 1rem;
  }
  .checkbox[data-size="lg"] {
    --checkbox--box--size: 1.25rem;
    --checkbox--box--radius: 0.375rem;
    --checkbox--font-size: 1rem;
  }
  .checkbox[data-size="xl"] {
    --checkbox--box--size: 1.5rem;
    --checkbox--box--radius: 0.375rem;
    --checkbox--gap: 0.625rem;
    --checkbox--font-size: 1rem;
  }

  /* Parts. The box and its check are centred in the first line, or in the hit area
     when that's taller, and so is the label's first line, however long it runs. */
  .checkbox > [data-slot="box"],
  .checkbox > [data-slot="indicator"] {
    grid-area: 1 / 1;
    box-sizing: border-box;
    width: var(--checkbox--box--size);
    height: var(--checkbox--box--size);
    margin: max(0px, (var(--checkbox--hit-area--size) - var(--checkbox--box--size)) / 2) 0;
  }
  .checkbox > [data-slot="box"] {
    /* Above the hit area, which is positioned too, so the box still gets its own hover and press. */
    position: relative;
    /* The browser's own box can't be sized or coloured the same way everywhere. */
    appearance: none;
    border: 1px solid var(--checkbox--box--border-color);
    border-radius: var(--checkbox--box--radius);
    background-color: transparent;
    /* Inputs don't inherit the text colour, which the border is mixed from. */
    color: inherit;
    cursor: inherit;
  }
  .checkbox > [data-slot="indicator"] {
    /* Positioned too, so it's drawn over the box, while clicks go through to it. */
    position: relative;
    pointer-events: none;
    color: var(--checkbox--indicator--color);
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  .checkbox > [data-slot="indicator"] > [data-mark] {
    display: none;
  }
  .checkbox > [data-slot="label"] {
    grid-row: 1;
    grid-column: 2;
    padding-block: max(0px, (var(--checkbox--hit-area--size) - 1.25 * var(--checkbox--font-size)) / 2);
  }

  /* States. Each one reads the <input>, which is what assistive technologies report,
     so what people see and what they hear can't drift apart. */
  @media (hover: hover) {
    .checkbox:hover:where(:not(:has(> [data-slot="box"]:is(:disabled, [aria-readonly="true"])))) {
      --checkbox--box--border-color: color-mix(in srgb, currentColor 80%, transparent);
    }
  }
  .checkbox > [data-slot="box"]:focus-visible {
    outline: 2px solid var(--color--focus);
    outline-offset: 2px;
  }
  .checkbox > [data-slot="box"]:is(:checked, :indeterminate) {
    --checkbox--box--border-color: var(--checkbox--box--fill);
    background-color: var(--checkbox--box--fill);
  }
  /* The browser clears :indeterminate on the first click, and the dash takes precedence
     over the check, as it does in native checkboxes. */
  .checkbox > [data-slot="box"]:checked:not(:indeterminate) + [data-slot="indicator"] > [data-mark="check"],
  .checkbox > [data-slot="box"]:indeterminate + [data-slot="indicator"] > [data-mark="dash"] {
    display: inline;
  }
  /* Read-only: dashed, like a read-only Input, with the check in the text colour and no
     fill, so it reads as a state you can see but not change. :read-only can't tell it
     apart, since it matches every checkbox. */
  .checkbox:has(> [data-slot="box"][aria-readonly="true"]) {
    cursor: default;
  }
  .checkbox > [data-slot="box"][aria-readonly="true"] {
    /* The container's border colour, which more contrast still strengthens. */
    --checkbox--box--border-color: inherit;
    border-style: dashed;
    background-color: transparent;
  }
  .checkbox > [data-slot="box"][aria-readonly="true"] + [data-slot="indicator"] {
    color: inherit;
  }
  .checkbox > [data-slot="box"][aria-invalid="true"] {
    --checkbox--box--border-color: var(--color--error-text);
    /* A second pixel of border, drawn inside, so the box doesn't grow. */
    box-shadow: inset 0 0 0 1px var(--checkbox--box--border-color);
  }
  /* :disabled matches inside a <fieldset disabled> too, so a whole group looks disabled. */
  .checkbox:has(> [data-slot="box"]:disabled) {
    opacity: 0.5;
    cursor: not-allowed;
  }

  /* With more contrast, the border is drawn in the text colour at full strength. */
  @media (prefers-contrast: more) {
    .checkbox,
    .checkbox:hover {
      --checkbox--box--border-color: currentColor;
    }
  }

  /* Forced colors mode keeps borders but drops backgrounds and shadows, so mark the
     checked box and its states with system colours. */
  @media (forced-colors: active) {
    .checkbox > [data-slot="box"]:is(:checked, :indeterminate) {
      --checkbox--box--border-color: Highlight;
      background-color: Highlight;
    }
    .checkbox > [data-slot="indicator"] {
      color: HighlightText;
    }
    .checkbox > [data-slot="box"][aria-readonly="true"] {
      --checkbox--box--border-color: CanvasText;
      background-color: transparent;
    }
    .checkbox > [data-slot="box"][aria-readonly="true"] + [data-slot="indicator"] {
      color: CanvasText;
    }
    .checkbox > [data-slot="box"]:focus-visible {
      outline-color: Highlight;
    }
    .checkbox:has(> [data-slot="box"]:disabled) {
      color: GrayText;
      opacity: 1;
    }
    .checkbox > [data-slot="box"]:disabled {
      --checkbox--box--border-color: GrayText;
    }
    .checkbox > [data-slot="box"]:disabled:is(:checked, :indeterminate) {
      background-color: GrayText;
    }
    .checkbox > [data-slot="box"]:disabled + [data-slot="indicator"] {
      color: Canvas;
    }
  }
}
</style>

FAQ

Checkbox or something else?

A Checkbox says yes or no to one thing. For anything else, another component will do a better job:

When people need to…Use
Say yes or no to one thing, like agreeing to the termsCheckbox
Pick any number of options out of a fewCheckbox Group
Flip a setting that takes effect right away, like Wi-FiSwitch
Pick one option out of a fewRadio Group
Pick one option, or several, out of manySelect, or Combobox
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, and they'll share the Checkbox's sizes, states and rules for labels and errors.

Sources

Copyright © 2026