Checkbox
A form control for selecting one or more options: checked, unchecked, and indeterminate states, five sizes and intents, rich labels, select-all groups, cards, and full-row mobile layouts.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Checkbox, CheckboxGroup } from "@tessinaui/ui";{/* Standalone */}
<Checkbox label="Accept terms and conditions" />
{/* Controlled */}
<Checkbox
checked={accepted}
onCheckedChange={(val) => setAccepted(val as boolean)}
label="Accept terms"
/>
{/* With description and error */}
<Checkbox
label="Accept terms"
required
errorMessage="You must accept the terms to continue."
/>
{/* Mobile settings row: text leading, control trailing, whole row tappable */}
<Checkbox label="Deals and offers" labelPlacement="start" fullWidth />When to use
- Checkbox — pick any number of options from a set, or a single yes/no that is submitted with a form (terms consent).
- Radio — exactly one of a set.
- Switch — a standalone setting with an immediate effect, no form submit.
- Chip (filter) — multi-select directly over the content it filters.
Write labels as short positive fragments in sentence case ("Send me updates", never "Don't send me emails"), no trailing punctuation. Labels wrap — they are never truncated.
Examples
Default
A basic checkbox with a label. The label, the box, and the space between them are one tap target.
Sizes
Five sizes from xs to xl; box, mark, and label scale together.
Intents
Semantic colors — none, error, warning, success, and info. The unchecked warning border and all helper text step to accessible token shades (see Accessibility).
States
Unchecked, checked, indeterminate, disabled, read-only, and loading. Read-only stays at full opacity and keeps keyboard focus but refuses interaction; loading shows a spinner in the box and reports aria-busy.
Description and error
Helper text below the label, or a validation message with an alert icon. The message is announced politely and colored by intent — with no explicit intent it defaults to error styling, so intent="warning" + errorMessage is a warning message, not an error repainted red.
Terms with links
label accepts ReactNode: links inside the label navigate without toggling the checkbox.
Group
A CheckboxGroup renders a single labeled <fieldset>; value/onValueChange manage the checked set by each child's name.
Select all (parent)
Pass parent to one checkbox inside a controlled group with allValues — Base UI derives its checked/indeterminate state and the mixed → all → none → last-partial cycle. Do not wire checked/indeterminate on the parent yourself.
Mobile rows
fullWidth + labelPlacement="start" is the settings-row shape: text leading, control trailing, the entire row one tap target.
Contact list
Checkbox composed with Avatar for selecting people.
Cards
CheckboxCard turns the entire surface into a selectable option with a title, description, and corner indicator.
API Reference
Checkbox
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Box 14/16/20/24/28px; label and mark scale with it |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic color for border, fill, and message |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Box corner radius. full is the circular selection indicator — there is no separate shape prop |
motion | "draw" | "fade" | "none" | "draw" | How the mark arrives. draw strokes the glyph along its own path; fade cross-fades it in at 150ms; none shows it instantly. All three flatten to an instant change under prefers-reduced-motion: reduce, so none is not the OS-level accessibility answer — reach for it for render cost on dense lists, or to wire your own in-app "reduce motion" setting |
checked / defaultChecked | boolean | — / false | Controlled / uncontrolled state |
onCheckedChange | (checked: boolean, eventDetails) => void | — | Change handler |
indeterminate | boolean | false | Mixed state (aria-checked="mixed", dash mark) |
disabled | boolean | false | Not focusable (aria-disabled, out of tab order) |
readOnly | boolean | false | Focusable but not operable; full-opacity display |
isLoading | boolean | false | Spinner in the box, aria-busy, interaction refused |
required | boolean | false | Native required + decorative asterisk |
optional | boolean | false | Muted "(optional)" marker; ignored when required |
label | ReactNode | — | Visible label; the wrapping <label> makes it the accessible name |
visuallyHiddenLabel | boolean | false | Keep the name, hide the text (sr-only) |
description | ReactNode | — | Helper text below the label; reaches the control via aria-describedby, never the name |
errorMessage | ReactNode | — | Validation message (replaces description), aria-live="polite", sets aria-invalid |
labelPlacement | "start" | "end" | "end" | Logical text side (RTL-correct) |
fullWidth | boolean | false | Row stretches; text column grows, box sits at the far edge |
dir | "ltr" | "rtl" | — | Text direction for the row |
name / value | string | — | Form submission; inside a group, name is the membership key |
parent | boolean | false | Select-all inside a controlled CheckboxGroup (needs allValues) |
className | string | — | Extra classes on the wrapper <div> |
Deprecated: labelPosition ("left" \| "right") — maps onto labelPlacement (left → start), removed next minor.
All other props (inputRef, uncheckedValue, nativeButton, form, render, ARIA/data attributes) are forwarded to the Base UI Checkbox.Root — a <span role="checkbox"> with a hidden <input> beside it.
CheckboxGroup
| Prop | Type | Default | Description |
|---|---|---|---|
value / defaultValue | string[] | — / [] | Controlled / uncontrolled checked names |
onValueChange | (value: string[], eventDetails) => void | — | Change handler |
allValues | string[] | — | Every value in the group — required for a parent checkbox |
disabled | boolean | false | Disables all children |
orientation | "vertical" | "horizontal" | "vertical" | Stack direction |
size / intent / rounded / motion | — | "md" / "none" / — / "draw" | Propagated to children unless they set their own |
label | ReactNode | — | Legend of the rendered <fieldset> |
description / errorMessage | ReactNode | — | Group helper / validation, both wired via aria-describedby |
required / optional | boolean | false | Legend markers only — put required on the controls themselves |
dir | "ltr" | "rtl" | — | Direction for the fieldset |
CheckboxCard
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | required | Card title = accessible name |
description | ReactNode | — | Exposed via aria-describedby, never the name |
icon | ReactNode | — | Leading icon slot |
size | CheckboxSize | "md" | Indicator size |
intent | CheckboxIntent | "none" | Border/fill/text semantics |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "xl" | Card corners; the corner indicator follows |
motion | "draw" | "fade" | "none" | "draw" | How the mark arrives. draw strokes the glyph along its own path; fade cross-fades it in at 150ms; none shows it instantly. All three flatten to an instant change under prefers-reduced-motion: reduce, so none is not the OS-level accessibility answer — reach for it for render cost on dense lists, or to wire your own in-app "reduce motion" setting |
dir | "ltr" | "rtl" | — | Direction |
Plus all Base UI root props (checked, value, disabled, …). CheckboxCardGroup adds columns (1–4), the same label/description/errorMessage fieldset chrome, and propagates size/intent/rounded/motion.
CheckboxSkeleton
size, rounded, labelPlacement, fullWidth, showLabel, showDescription, showError, labelChars, descriptionChars, dir — mirrors the real boxes exactly (the frame is the checkbox's own cva).
Do / Don't
- Do use one
Checkboxfor a submitted yes/no (terms); don't use a Switch there — switches imply immediate effect. - Do give a
parentcheckbox a controlled group withallValues; don't hand-wire itschecked/indeterminate. - Do keep labels positive ("Email me"); don't negate ("Don't email me") — checking a negative is a double negative.
- Do use
errorMessageon the group for "pick at least one"; don't mark every child invalid. - Do use
rounded="full"for circular mobile selection indicators; don't invent a shape prop or confuse circles with radios in forms.
Accessibility
Pattern: WAI-ARIA APG Checkbox (tri-state for parent).
| Key | Action |
|---|---|
Tab / Shift+Tab | Move focus between checkboxes (each is a tab stop) |
Space | Toggle the focused checkbox |
Enter | Nothing — deliberately suppressed to match the APG (Base UI's default would toggle) |
- The row is a wrapping
<label>: box, text, and gap are one click/tap target; nested links in a rich label navigate without toggling. - Accessible name = label content. The description is
aria-hiddeninside the label and reaches the control viaaria-describedby(hidden referents resolve); the required asterisk is decorative — the control's ownrequired/aria-requiredcarries the semantic. errorMessagerenders in the text column as anaria-live="polite"region, setsaria-invalid, and joinsaria-describedby; it cannot join the accessible name because the name comes from the label text span viaaria-labelledby.aria-checked="mixed"for indeterminate; aparentcheckbox announces the mixed state and controls the set.- Groups render one
<fieldset>captioned by its<legend>— a singlerole="group"announcement (the fieldset is the Base UI group element). - Disabled =
aria-disabled, removed from the tab order. Read-only = focusable,aria-readonly, not operable (review-mode pattern). Loading =aria-busy+ refused interaction. - Touch targets: 44×44px hit area per box on touch viewports with ≥44px row pitch (overlays shrink to the 24px WCAG 2.5.8 minimum at
md+so stacked rows never overlap);fullWidthmakes the whole row the target. - Contrast (hard-gated in
pnpm audit:contrast, light + dark): unchecked border--outline-border3.03:1/3.01:1; warning unchecked border + helper text usewarning-tinted-foreground(7.14:1 light); helper/error text mirrors Label's map; checked fills use the lockedon-*glyph pairs; card selected ink 4.72–13.97:1. - Forced colors (Windows High Contrast): states map to
SelectedItem/SelectedItemText/GrayTextso checked and unchecked stay distinguishable. - Reduced motion: the mark's 150ms fade/zoom, press scale, and spinner rotation are all disabled or substituted under
prefers-reduced-motion. - AT matrix: VoiceOver + NVDA passes pending (status stays
betauntil both are on record).