Checkbox
A 16px square for independent on and off choices and row selection.
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
The Quiet Indigo Rule
Anatomy#
- Box. 16px, 4px corners, a 1px Field Stroke border. In dark it takes the input color at 30%.
- Indicator. A 14px lucide check, or a dash when
indeterminate, in Indigo Paper on the Quiet Indigo fill. - Hit area. An invisible
::afterthat extends 12px left and right and 8px up and down, so the target is 40 by 32px. - 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.
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.
- INV-20398Northwind FreightOct 2$4,120.00
- INV-20417Halcyon LogisticsOct 12$18,240.00
- INV-20422Orchard Street SupplyOct 14$912.50
- INV-20431Northwind FreightOct 16$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#
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> );}| State | Treatment |
|---|---|
| Unchecked | Field Stroke border, transparent fill. |
| Checked | Quiet Indigo border and fill with a white check. |
| Indeterminate | Quiet Indigo fill with a white dash. Announced as mixed. Set it when some, not all, children are checked. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 50%. Inside a choice-card FieldLabel, the card draws the ring instead. |
| Invalid | With aria-invalid, a red border and a 3px red ring at 20%. A checked invalid box keeps its indigo border. |
| Disabled | 50% opacity with a not-allowed cursor, checked or not. Inside a Field that contains a disabled control, it dims with the field. |
| Read-only | readOnly 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 theid,nameandvalue, so it submits with a native form and links to a<Label htmlFor>. - Controlled with
checkedandonCheckedChange(checked, eventDetails), or uncontrolled withdefaultChecked. indeterminateis controlled by you. Clicking an indeterminate box callsonCheckedChangewith the opposite ofchecked, so a select-all withchecked={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#
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>, oraria-labelfor row checkboxes (Select INV-20417). indeterminateis announced as mixed througharia-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.
| Keys | Action |
|---|---|
| Tab | Moves focus to the checkbox. |
| Space | Toggles it. From indeterminate, sets it to the opposite of checked. |
| Enter | Does nothing, by design. |
Design tokens#
| Token | Used for |
|---|---|
--input | Unchecked border; 30% fill in dark |
--primary | Checked and indeterminate border and fill |
--primary-foreground | The check and the dash |
--ring | Focus border and 3px ring at 50% |
--destructive | Invalid 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | No default | Controlled checked state. |
defaultChecked | boolean | false | Initial state when uncontrolled. |
onCheckedChange | (checked: boolean, eventDetails: Checkbox.Root.ChangeEventDetails) => void | No default | Called with the next state. |
indeterminate | boolean | false | Shows the dash and announces mixed. You compute it from the children. |
disabled | boolean | false | Dims to 50% and blocks changes. |
readOnly | boolean | false | Blocks changes but stays focusable. |
required | boolean | false | Must be checked for the form to submit. |
id | string | No default | Set on the hidden input, for <Label htmlFor>. |
name | string | No default | Form field name. |
value | string | No default | Value submitted when checked. |
uncheckedValue | string | No default | Value submitted when unchecked. Omitted, nothing is submitted. |
parent | boolean | false | Marks the parent of a Base UI CheckboxGroup, which then manages indeterminate for you. Not wrapped in packages/canon. |
inputRef | React.Ref<HTMLInputElement> | No default | Ref to the hidden input. |
className | string | No default | Merged 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.