Skip to content

Checkbox

A 16px square for independent on and off choices and row selection.

Status
Stable
Level
Atom
Category
Selection
Adoption
Not used yet
import { Checkbox } from "@oration/canon/components/checkbox";
packages/canon/src/components/checkbox.tsx

Approval rules

Applied to every invoice before it joins a payment run.

Priya Raman or Tomás Ferreira must approve large runs.

Invoices without a PO wait in Exceptions.

Invoices still post; only payment waits.

import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const id = React.useId();    const [rules, setRules] = React.useState({        second: true,        po: true,        w9: false,    });    const options = [        {            key: "second" as const,            label: "Require a second approver over $25,000",            description:                "Priya Raman or Tomás Ferreira must approve large runs.",        },        {            key: "po" as const,            label: "Match invoices to an open PO",            description: "Invoices without a PO wait in Exceptions.",        },        {            key: "w9" as const,            label: "Hold payment until a W-9 is on file",            description: "Invoices still post; only payment waits.",        },    ];    return (        <form            className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg"            onSubmit={(event) => {                event.preventDefault();                const on = Object.values(rules).filter(Boolean).length;                toast.add({                    type: "success",                    title: "Approval rules saved",                    description: `${on} of 3 rules apply from the next payment run.`,                });            }}        >            <div className="flex flex-col gap-1 p-4 pb-2">                <p className="text-base leading-none font-medium text-foreground">                    Approval rules                </p>                <p className="text-sm text-muted-foreground">                    Applied to every invoice before it joins a payment run.                </p>            </div>            <div className="flex flex-col gap-4 p-4">                {options.map((option) => (                    <div key={option.key} className="flex items-start gap-3">                        <Checkbox                            id={`${id}-${option.key}`}                            checked={rules[option.key]}                            onCheckedChange={(checked) =>                                setRules((current) => ({                                    ...current,                                    [option.key]: checked,                                }))                            }                            aria-describedby={`${id}-${option.key}-description`}                            className="mt-0.5"                        />                        <div className="flex flex-col gap-1">                            <Label                                htmlFor={`${id}-${option.key}`}                                className="leading-snug"                            >                                {option.label}                            </Label>                            <p                                id={`${id}-${option.key}-description`}                                className="text-13 text-muted-foreground"                            >                                {option.description}                            </p>                        </div>                    </div>                ))}            </div>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button                    type="button"                    variant="ghost"                    onClick={() =>                        setRules({ second: true, po: true, w9: false })                    }                >                    Reset                </Button>                <Button type="submit">Save rules</Button>            </div>        </form>    );}

Usage#

Checkbox is a 16px square with 4px corners for choices that are independently on or off: a consent, an option in a list, a row in a table. Checked and indeterminate fill Quiet Indigo with a white check or dash. It is built on Base UI Checkbox, supports an indeterminate parent for select-all, and widens its hit area past the 16px box. The common mistake is using it for a setting that applies the moment it flips; that is a Switch.

When to use

  • For an independent yes or no that is saved with the form: Require a second approver over $25,000.
  • For picking any number of options from a short visible list, such as the documents a supplier must send.
  • For selecting rows in a list or table, with an indeterminate select-all in the header.
  • For a parent option over a set of children, such as a group of notification events.
  • For a consent or confirmation the person must tick before a submit: The bank details match the supplier's letter.

When not to use

  • For a setting that takes effect immediately, without a save. Use Switch
  • For exactly one choice out of several. Use Radio group
  • For a choice that needs a title, a description and room to compare, drawn as a card. Use Choice card
  • For a pressed state on a toolbar button, such as bold or wrap. Use Toggle
  • For filtering a list by several values from a long set. Use Filter chip

Checkbox saves, Switch applies

A checkbox is part of a form: its value is sent when the person saves. If flipping it changes something right away, it is a Switch.

The Quiet Indigo Rule

Checked is selection, one of the few places indigo is spent. The fill is the only color; the label beside it stays ink.

Anatomy#

  1. Box. 16px, 4px corners, a 1px Field Stroke border. In dark it takes the input color at 30%.
  2. Indicator. A 14px lucide check, or a dash when indeterminate, in Indigo Paper on the Quiet Indigo fill.
  3. Hit area. An invisible ::after that extends 12px left and right and 8px up and down, so the target is 40 by 32px.
  4. Label. A Label that wraps the box or points at it with htmlFor, 8px away. Clicking it toggles the box.

Examples#

With a label

Wrap the box and its text in one Label so the whole line toggles. Say what the choice does in a line below it.

Northwind Freight gets a PDF after every payment run.

import { Checkbox } from "@oration/canon/components/checkbox";import { Label } from "@oration/canon/components/label";import * as React from "react";export function WithLabel() {    const [advice, setAdvice] = React.useState(true);    return (        <div className="flex flex-col gap-3">            <Label>                <Checkbox checked={advice} onCheckedChange={setAdvice} />                Email remittance advice to the supplier            </Label>            <p className="text-13 text-muted-foreground">                {advice                    ? "Northwind Freight gets a PDF after every payment run."                    : "Northwind Freight won't be told when a payment is sent."}            </p>        </div>    );}

Group

A fieldset and legend ask the question once; each option is a Label at weight 400. Every box shares a name and carries its own value, so the group submits as a list.

Documents required before the first payment

New suppliers are asked for these when they're invited.

import { Checkbox } from "@oration/canon/components/checkbox";import { Label } from "@oration/canon/components/label";import * as React from "react";export function Group() {    const id = React.useId();    const [docs, setDocs] = React.useState<string[]>(["w9", "bank"]);    const options = [        { value: "w9", label: "W-9" },        { value: "coi", label: "Certificate of insurance" },        { value: "bank", label: "Bank letter on letterhead" },        { value: "msa", label: "Signed master services agreement" },    ];    return (        <fieldset            className="flex flex-col gap-3"            aria-describedby={`${id}-description`}        >            <legend className="text-sm font-medium">                Documents required before the first payment            </legend>            <p                id={`${id}-description`}                className="-mt-1 text-13 text-muted-foreground"            >                New suppliers are asked for these when they're invited.            </p>            {options.map((option) => (                <Label key={option.value} className="font-normal">                    <Checkbox                        name="required-documents"                        value={option.value}                        checked={docs.includes(option.value)}                        onCheckedChange={(checked) =>                            setDocs((current) =>                                checked                                    ? [...current, option.value]                                    : current.filter(                                          (value) => value !== option.value,                                      ),                            )                        }                    />                    {option.label}                </Label>            ))}        </fieldset>    );}

Indeterminate

A parent over its children shows a dash when some are on. Clicking it turns every child on; clicking again turns them all off.

import { Checkbox } from "@oration/canon/components/checkbox";import { Label } from "@oration/canon/components/label";import * as React from "react";export function Indeterminate() {    const events = [        { value: "approved", label: "Invoice approved" },        { value: "scheduled", label: "Payment run scheduled" },        { value: "sent", label: "Payment sent" },        { value: "failed", label: "Payment failed" },    ];    const [on, setOn] = React.useState<string[]>(["sent", "failed"]);    const all = on.length === events.length;    const some = on.length > 0 && !all;    return (        <div className="flex flex-col gap-3">            <Label>                <Checkbox                    checked={all}                    indeterminate={some}                    onCheckedChange={(checked) =>                        setOn(checked ? events.map((event) => event.value) : [])                    }                />                Payment events                <span className="font-normal text-muted-foreground tabular-nums">                    {on.length} of {events.length}                </span>            </Label>            <div className="flex flex-col gap-3 pl-6">                {events.map((event) => (                    <Label key={event.value} className="font-normal">                        <Checkbox                            checked={on.includes(event.value)}                            onCheckedChange={(checked) =>                                setOn((current) =>                                    checked                                        ? [...current, event.value]                                        : current.filter(                                              (value) => value !== event.value,                                          ),                                )                            }                        />                        {event.label}                    </Label>                ))}            </div>        </div>    );}

Row selection

Row checkboxes appear on hover or focus and stay once any row is selected. Selected rows take the 6% indigo tint, the header goes indeterminate and shows the count with a bulk action.

4 invoices awaiting approval
  • INV-20398Northwind Freight$4,120.00
  • INV-20417Halcyon Logistics$18,240.00
  • INV-20422Orchard Street Supply$912.50
  • INV-20431Northwind Freight$2,760.00
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function RowSelection() {    const invoices = [        {            id: "INV-20398",            supplier: "Northwind Freight",            due: "Oct 2",            amount: "$4,120.00",        },        {            id: "INV-20417",            supplier: "Halcyon Logistics",            due: "Oct 12",            amount: "$18,240.00",        },        {            id: "INV-20422",            supplier: "Orchard Street Supply",            due: "Oct 14",            amount: "$912.50",        },        {            id: "INV-20431",            supplier: "Northwind Freight",            due: "Oct 16",            amount: "$2,760.00",        },    ];    const [selected, setSelected] = React.useState<string[]>([]);    const all = selected.length === invoices.length;    const any = selected.length > 0;    return (        <div className="w-full max-w-xl overflow-hidden rounded-xl bg-card shadow-border">            <div className="flex h-10 items-center gap-3 border-b border-border px-3">                <Checkbox                    aria-label="Select all invoices"                    checked={all}                    indeterminate={any && !all}                    onCheckedChange={(checked) =>                        setSelected(                            checked                                ? invoices.map((invoice) => invoice.id)                                : [],                        )                    }                />                {any ? (                    <>                        <span className="text-13 font-medium tabular-nums">                            {selected.length} selected                        </span>                        <Button                            type="button"                            variant="outline"                            size="sm"                            className="ml-auto"                            onClick={() => {                                toast.add({                                    type: "success",                                    title:                                        selected.length === 1                                            ? `Approved ${selected[0]}`                                            : `Approved ${selected.length} invoices`,                                });                                setSelected([]);                            }}                        >                            Approve{" "}                            {selected.length === 1                                ? "invoice"                                : `${selected.length} invoices`}                        </Button>                    </>                ) : (                    <span className="text-13 text-muted-foreground">                        4 invoices awaiting approval                    </span>                )}            </div>            <ul>                {invoices.map((invoice) => {                    const isSelected = selected.includes(invoice.id);                    return (                        <li                            key={invoice.id}                            className={cn(                                "group/row flex h-9 items-center gap-3 border-b border-border px-3 text-13 last:border-b-0",                                isSelected                                    ? "bg-primary/[0.06]"                                    : "hover:bg-muted/60",                            )}                        >                            <Checkbox                                aria-label={`Select ${invoice.id}`}                                checked={isSelected}                                onCheckedChange={(checked) =>                                    setSelected((current) =>                                        checked                                            ? [...current, invoice.id]                                            : current.filter(                                                  (value) =>                                                      value !== invoice.id,                                              ),                                    )                                }                                className={cn(                                    !any &&                                        "opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100",                                )}                            />                            <span className="w-20 shrink-0 font-mono text-xs">                                {invoice.id}                            </span>                            <span className="min-w-0 flex-1 truncate">                                {invoice.supplier}                            </span>                            <span className="hidden w-14 text-muted-foreground sm:block">                                {invoice.due}                            </span>                            <span className="w-24 text-right tabular-nums">                                {invoice.amount}                            </span>                        </li>                    );                })}            </ul>        </div>    );}

Required confirmation

For a confirmation that must be ticked, check on submit. Mark the box aria-invalid, show the reason under the label and move focus to the box.

import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Invalid() {    const id = React.useId();    const [confirmed, setConfirmed] = React.useState(false);    const [error, setError] = React.useState(false);    return (        <form            noValidate            className="flex w-full max-w-sm flex-col gap-4"            onSubmit={(event) => {                event.preventDefault();                if (!confirmed) {                    setError(true);                    document.getElementById(`${id}-confirm`)?.focus();                    return;                }                setError(false);                toast.add({                    type: "success",                    title: "Bank details updated",                    description:                        "Northwind Freight's next payment goes to the new account.",                });            }}        >            <div className="flex items-start gap-3">                <Checkbox                    id={`${id}-confirm`}                    checked={confirmed}                    onCheckedChange={(checked) => {                        setConfirmed(checked);                        if (checked) setError(false);                    }}                    aria-invalid={error || undefined}                    aria-describedby={error ? `${id}-confirm-error` : undefined}                    className="mt-0.5"                />                <div className="flex flex-col gap-1">                    <Label htmlFor={`${id}-confirm`} className="leading-snug">                        I called Northwind Freight and confirmed the new account                        number                    </Label>                    {error ? (                        <p                            id={`${id}-confirm-error`}                            className="text-13 text-destructive"                        >                            Confirm by phone before changing where payments go.                        </p>                    ) : null}                </div>            </div>            <Button type="submit" variant="outline" className="self-start">                Update bank details            </Button>        </form>    );}

States#

RestFocusInvalidDisabled
Unchecked
Checked
Indeterminate
import { Checkbox } from "@oration/canon/components/checkbox";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() {    const columns = ["Rest", "Focus", "Invalid", "Disabled"] as const;    const rows = [        { name: "Unchecked", checked: false, indeterminate: false },        { name: "Checked", checked: true, indeterminate: false },        { name: "Indeterminate", checked: false, indeterminate: true },    ];    return (        <div className="grid w-full min-w-0 grid-cols-[6.5rem_repeat(4,minmax(0,1fr))] items-center gap-x-2 gap-y-4">            <span />            {columns.map((column) => (                <span                    key={column}                    className="text-center text-xs text-muted-foreground"                >                    {column}                </span>            ))}            {rows.map((row) => (                <div key={row.name} className="contents">                    <span className="text-13 text-muted-foreground">                        {row.name}                    </span>                    {columns.map((column) => (                        <div key={column} className="flex justify-center">                            <Checkbox                                aria-label={`${row.name}, ${column.toLowerCase()}`}                                tabIndex={-1}                                checked={row.checked}                                indeterminate={row.indeterminate}                                disabled={column === "Disabled"}                                aria-invalid={column === "Invalid" || undefined}                                className={cn(                                    "pointer-events-none",                                    column === "Focus" &&                                        "border-ring ring-3 ring-ring/50",                                )}                            />                        </div>                    ))}                </div>            ))}        </div>    );}
States
StateTreatment
UncheckedField Stroke border, transparent fill.
CheckedQuiet Indigo border and fill with a white check.
IndeterminateQuiet Indigo fill with a white dash. Announced as mixed. Set it when some, not all, children are checked.
Focus visibleIndigo border and a 3px Focus Indigo ring at 50%. Inside a choice-card FieldLabel, the card draws the ring instead.
InvalidWith aria-invalid, a red border and a 3px red ring at 20%. A checked invalid box keeps its indigo border.
Disabled50% opacity with a not-allowed cursor, checked or not. Inside a Field that contains a disabled control, it dims with the field.
Read-onlyreadOnly blocks changes and sets data-readonly, with no visual change.

Behavior#

  • Base UI renders a <span role="checkbox"> with a hidden <input type="checkbox"> beside it that carries the id, name and value, so it submits with a native form and links to a <Label htmlFor>.
  • Controlled with checked and onCheckedChange(checked, eventDetails), or uncontrolled with defaultChecked.
  • indeterminate is controlled by you. Clicking an indeterminate box calls onCheckedChange with the opposite of checked, so a select-all with checked={false} selects everything.
  • Space toggles. Enter does not, so pressing Enter in a form with a checkbox focused won't flip it.
  • The fill and border change over 150ms; the check itself appears with no transition.
  • For row selection, show row checkboxes on row hover or focus, and keep them visible once any row is selected.

Do and don't#

Do. Use a checkbox for a choice that is saved with the rest of the form.
Don't. Use a checkbox for a setting that applies the moment it's clicked. People look for a save button that never comes.
2 selected
INV-20398
INV-20417
INV-20422
Do. Show an indeterminate select-all when some rows are selected, and say how many.
Invoice
INV-20398
INV-20417
INV-20422
Don't. Leave the select-all unchecked while rows below it are checked. It reads as nothing selected.
Do. Write the label as the statement that becomes true when checked.
Don't. Write a negative label, such as Don't email remittance advice. Checking a box to turn something off is a double negative.

Content#

  • Write labels as positive statements that the check makes true: Email remittance advice to the supplier.
  • Sentence case, no trailing period, unless the label is a full sentence of consent.
  • Keep the label on one line where you can; put the consequence in a description below it.
  • A select-all needs no visible text in a table header, but it needs an aria-label: Select all invoices.

Accessibility#

  • Every checkbox needs a name: a wrapping Label, a <Label htmlFor>, or aria-label for row checkboxes (Select INV-20417).
  • indeterminate is announced as mixed through aria-checked="mixed".
  • Group related checkboxes in a fieldset with a legend so the group's question is announced once.
  • The 16px box has a 40 by 32px hit area through its ::after, so leave at least 8px between stacked rows.
  • Row checkboxes that appear on hover must also appear on keyboard focus, or keyboard users select rows blind.
  • Don't disable a required checkbox to force order; leave it enabled and explain the error on submit.
Keyboard interactions
KeysAction
TabMoves focus to the checkbox.
SpaceToggles it. From indeterminate, sets it to the opposite of checked.
EnterDoes nothing, by design.

Design tokens#

Design tokens
TokenUsed for
--inputUnchecked border; 30% fill in dark
--primaryChecked and indeterminate border and fill
--primary-foregroundThe check and the dash
--ringFocus border and 3px ring at 50%
--destructiveInvalid border and ring
rounded-[4px]The 4px checkbox corner

API reference#

Checkbox

A styled Base UI Checkbox.Root with its Indicator. Renders data-slot="checkbox", and Base UI sets data-checked, data-unchecked, data-indeterminate, data-disabled, data-readonly and data-invalid.

Other props spread onto Base UI Checkbox.Root (<span>).

Props of Checkbox
PropTypeDefaultDescription
checkedbooleanNo defaultControlled checked state.
defaultCheckedbooleanfalseInitial state when uncontrolled.
onCheckedChange(checked: boolean, eventDetails: Checkbox.Root.ChangeEventDetails) => voidNo defaultCalled with the next state.
indeterminatebooleanfalseShows the dash and announces mixed. You compute it from the children.
disabledbooleanfalseDims to 50% and blocks changes.
readOnlybooleanfalseBlocks changes but stays focusable.
requiredbooleanfalseMust be checked for the form to submit.
idstringNo defaultSet on the hidden input, for <Label htmlFor>.
namestringNo defaultForm field name.
valuestringNo defaultValue submitted when checked.
uncheckedValuestringNo defaultValue submitted when unchecked. Omitted, nothing is submitted.
parentbooleanfalseMarks the parent of a Base UI CheckboxGroup, which then manages indeterminate for you. Not wrapped in packages/canon.
inputRefReact.Ref<HTMLInputElement>No defaultRef to the hidden input.
classNamestringNo defaultMerged after the base classes, on the box.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

There is no hover style. The box doesn't change until it is pressed, where DESIGN.md gives every control a hover response.

packages/canon doesn't wrap Base UI's CheckboxGroup, so every select-all in the product computes indeterminate by hand.

readOnly looks identical to an editable checkbox.