User Interface (UI)
Let's start with how a checkbox looks: its parts, its sizes and how it shows each of its states.
Anatomy
- Container — The
<label>, which wraps the box and its label, so a press on either toggles it. It carries the size. - Box — The
<input type="checkbox">itself, which holds the state and takes focus. - Indicator — A check, or a dash while it's indeterminate, drawn over the box and hidden from assistive technologies.
- Label — Optional. The text that says what checking the box means, which names it.
- Hit area — At least 24 × 24px around the box, which a press anywhere in toggles it.
- 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 */}
| Size | Box | Text | Gap | Radius | Hit area |
|---|---|---|---|---|---|
xs | 14px | 12px | 6px | 4px | 24px |
sm | 16px | 14px | 8px | 4px | 24px |
md | 18px | 14px | 8px | 4px | 24px |
lg | 20px | 16px | 8px | 6px | 24px |
xl | 24px | 16px | 10px | 6px | 24px |
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.
Agree to the terms to create an account
<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>
| State | Selector | Recommended look |
|---|---|---|
| Hover | :hover, inside @media (hover: hover) | The border at 80% of the text colour, up from 55% |
| Focus | > input:focus-visible | A 2px outline, 2px from the box, in --color--focus |
| Checked | > input:checked | The box filled with --color--primary, with a check on top |
| Indeterminate | > input:indeterminate | The 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:
| Token | Used for |
|---|---|
--color--neutral-text | The label, and mixed into the box's border |
--color--primary | The fill of a checked or indeterminate box |
--color--primary-contrast | The check and the dash, on top of the fill |
--color--error-text | The border of an invalid box |
--color--focus | The 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:
import styles from "./Terms.module.css";
export function Terms() {
return <Checkbox name="terms" className={styles.square}>I agree to the terms</Checkbox>;
}
.square {
--checkbox--box--radius: 0;
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
checkbox/contrast | Must | Component | The label has a contrast of 4.5:1, and the focus ring has 3:1 against the page | WCAG 1.4.3, 1.4.11 (AA) | axe color-contrast, contrast checker |
checkbox/boundary-contrast | Must | Component | The 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 fill | WCAG 1.4.11 (AA) | Contrast checker |
checkbox/focus-visible | Must | Component | Keyboard focus shows an outline at least 2px thick around the box, outside its border, on :focus-visible | WCAG 2.4.7 (AA), 2.4.13 (AAA) | Keyboard |
checkbox/target-size | Must | Component | The box has a hit area of at least 24 × 24px that toggles it, without counting its label | Open Components, beyond WCAG 2.5.8 (AA) | axe target-size, emulation |
checkbox/distinct-marks | Must | Component | Checked shows a check and indeterminate a dash, so the two never rely on colour to tell them apart | WCAG 1.4.1 (A) | Visual regression test |
checkbox/resizable | Must | Component | The box, its gap and its hit area are sized in rem, and the label wraps beside the box instead of being clipped | Open Components, beyond WCAG 1.4.4, 1.4.12 (AA) | 200% zoom |
checkbox/stable-size | Must | Component | No state changes the checkbox's size or position | Open Components | Visual regression test |
checkbox/state-selectors | Should | Component | States are styled from the <input>, with :checked, :indeterminate, [aria-readonly], :disabled and [aria-invalid], and never from :invalid or :read-only | Open Components | Review |
checkbox/hover-capable | Should | Component | Hover styles only apply inside @media (hover: hover) | Open Components | Review |
checkbox/forced-colors | Must | Component | The box, its check or dash, and its focus, read-only and disabled states stay visible in forced colours mode | Open Components | Emulation |
checkbox/more-contrast | Should | Component | The box's border is drawn at full strength when prefers-contrast: more is set | Open Components | Emulation |
FAQ
switch role, and rules of its own for what it announces.Overview
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.
User Experience
How a checkbox behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.