Checkbox
A checkbox is a yes-or-no choice. It can be checked, unchecked or indeterminate, and it can be disabled.
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
export default function CheckboxDemo() {
return (
<>
<Checkbox aria-label="Unchecked" />
<Checkbox aria-label="Checked" defaultChecked />
<Checkbox aria-label="Indeterminate" indeterminate />
<Checkbox aria-label="Disabled" disabled />
<Checkbox aria-label="Disabled and checked" disabled defaultChecked />
<Label>
<Checkbox defaultChecked />
Accept the terms
</Label>
</>
);
}The code
The component is a single file, read from the repository when the site is built. Press Show code to see all of it.
import type { ComponentProps } from "react";
import { Check, Minus } from "lucide-react";
import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
import { cx } from "@/lib/cx";
import { focusRing } from "@/lib/styles";
// Checked and indeterminate fill with the accent. A disabled checkbox keeps its mark but loses
// the accent, and never reacts to hover. The two icons swap on the root's data-indeterminate.
const root = [
"group inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-text-secondary text-bg",
"transition-colors duration-150",
focusRing,
"not-data-[disabled]:hover:border-text",
"not-data-[disabled]:data-[checked]:border-accent not-data-[disabled]:data-[checked]:bg-accent",
"not-data-[disabled]:data-[indeterminate]:border-accent not-data-[disabled]:data-[indeterminate]:bg-accent",
"data-[disabled]:cursor-not-allowed data-[disabled]:border-border data-[disabled]:bg-surface data-[disabled]:text-text-secondary",
].join(" ");
export function Checkbox({
className,
...props
}: Omit<ComponentProps<typeof BaseCheckbox.Root>, "className"> & { className?: string }) {
return (
<BaseCheckbox.Root className={cx(root, className)} {...props}>
<BaseCheckbox.Indicator className="flex">
<Check aria-hidden strokeWidth={3} className="size-3.5 group-data-[indeterminate]:hidden" />
<Minus aria-hidden strokeWidth={3} className="hidden size-3.5 group-data-[indeterminate]:block" />
</BaseCheckbox.Indicator>
</BaseCheckbox.Root>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
defaultChecked | boolean | false | Whether it starts checked, when you let it manage its own state. |
checked | boolean | — | The checked state, when you control it yourself. |
onCheckedChange | function | — | Called with the new value when the checkbox is toggled. |
indeterminate | boolean | false | Shows a dash instead of a check, for a group that is partly selected. |
disabled | boolean | false | Turns off the checkbox and its hover state. |
className | string | — | Extra classes, added after the checkbox's own. |
Every other prop, such as name and value, goes to the Base UI Checkbox underneath.
The thinking
A checkbox answers one question with yes or no, and its box should show the answer without any explanation.
- Built on Base UI: Base UI keeps the checked state, the hidden form input and the keyboard behavior. This file draws the box.
- A mark for each state: A check for checked and a dash for indeterminate. They swap on a data attribute, with no script.
- Put it in a label: Wrap the checkbox in a Label and the text becomes clickable too, which also makes the target larger.
Interface design
- Size: A 20px square with a 1px outline and the sm radius, 4px.
- Outline: The secondary text color, 4.2:1 against the page, so the box is easy to find when it is empty.
- Checked: Fills with the accent and draws the mark in the page color, 14.6:1. Indeterminate looks the same, with a dash.
- Disabled: A surface fill, a hairline border and a grey mark. A disabled checkbox that is checked still shows its mark.
Interaction
- Toggle: Click it, press Space when it has focus, or click the text of its label.
- Hover: An unchecked box's outline brightens to the main text color.
- Focus: Keyboard focus draws a 2px outline, 2px away from the box. A mouse click doesn't show it.
- Disabled: It doesn't toggle, doesn't react to hover, and shows a not-allowed cursor.
Empathy
- Real semantics: It is announced as a checkbox with its checked state, and as mixed when indeterminate.
- Always name it: A box needs text. Wrap it in a Label, or give it an
aria-labelas the bare boxes in the demo do. - Target size: The box alone is 20px, under the 24px minimum of WCAG 2.2 AA. Wrapped in a Label, the whole row is the target, so do that.