Overview
<Checkbox /> is the canonical boolean control. It renders a real <input type="checkbox">
(visually hidden but keyboard-reachable and form-participating) alongside a custom-painted
indicator span, all wrapped in a <label>. Clicking the label toggles the box natively — no
synthetic click handlers required.
Checkbox establishes the hidden-input + custom-indicator pattern reused by Switch
and Radio. The recipe ships three independently overridable slots: root, control, label.
Overview — unchecked, checked, indeterminate, and labeled
Tri-state
Checkbox supports three visual states — unchecked, checked, and indeterminate. Per native
HTML and ARIA semantics, indeterminate is a separate axis from checked: the indeterminate
look is drawn whenever indeterminate && !checked, and aria-checked="mixed" is set
automatically.
The classic "parent-of-children" pattern works out of the box — see the Indeterminate example.
Anatomy
[ control box | label / description column ][ control box | label / description column ]- control — the custom-painted indicator. Variant × color × shape lives here.
- label —
children. Click target via the wrapping<label htmlFor>. - description — optional secondary text below the label, wired via
aria-describedby.
The control box is decorative (aria-hidden) — assistive tech reads the underlying <input>.
Examples
Basic
Variants
Sizes
Colors
Shapes
Indeterminate
WithDescription
Disabled
Invalid
Group
Controlled
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | — | Controlled checked state. |
| color | ResponsiveValue<CheckboxColor> | 'primary' | Semantic palette role driving checked fill / outline / glyph color. |
| defaultChecked | boolean | false | Uncontrolled initial checked state. |
| description | ReactNode | — | Optional secondary text rendered below the label, wired via `aria-describedby`. |
| indeterminate | boolean | — | Tri-state visual. Sets `aria-checked="mixed"` and applies the indeterminate glyph + styling. Per native HTML semantics, `indeterminate` is a separate axis from `checked` — the component renders the indeterminate look whenever `indeterminate && !checked`. |
| invalid | boolean | — | Visual + a11y invalid state (sets `aria-invalid` + `data-invalid`). |
| labelPosition | enum | 'right' | Where the label renders relative to the box. Logical: `right` = end side in LTR, start side in RTL. |
| onChange | ((event: ChangeEvent<HTMLInputElement, Element>) => void) | — | Native change handler. Preserved alongside `onCheckedChange`. |
| onCheckedChange | ((checked: boolean) => void) | — | Canonical handler — receives the new boolean directly. Use alongside or instead of `onChange` (which still fires with the native `ChangeEvent`). |
| shape | enum | 'square' | Corner shape of the indicator box. |
| size | ResponsiveValue<CheckboxSize> | 'md' | Box + glyph + label size. |
| sx | Sx | — | Theme-aware inline style object (resolves palette / spacing / radius tokens to CSS vars). |
| variant | ResponsiveValue<CheckboxVariant> | 'solid' | Stylistic family of the checked indicator. |
Accessibility
- Renders a real
<input type="checkbox">inside a<label>— clicking the label toggles the state natively. Form participation (name+value) works exactly like a native checkbox. - The native input is visually hidden via
sr-only(Tailwind) — focus, form participation, and screen-reader announcements all flow through it. aria-checked="mixed"is set automatically whenindeterminate && !checked.- Focus ring lands on the control box, not just the hidden input, via the
peer-focus-visiblevariant. The ring color matches the activecolorfor every variant × color cell. Spacetoggles;Enteris a no-op (matches native checkbox + Radix convention).invalidsetsaria-invalidon the input anddata-invalidon the control.descriptiontext gets an auto-generated id and is wired intoaria-describedby. If the consumer also supplies their ownaria-describedby, both ids are merged space-separated.- A dev-mode warning fires when neither
children,aria-label, noraria-labelledbyis provided (CHECKBOX_NO_LABEL). - axe-core passes across the variant × color × state matrix.
Theming
Each of the three slots can be overridden independently:
<ThemeProvider
theme={defineTheme({
components: {
Checkbox: {
defaultProps: { variant: 'outline', shape: 'rounded' },
styleOverrides: {
root: 'gap-3',
control: 'shadow-xs',
label: 'font-medium',
},
},
},
})}
>
{children}
</ThemeProvider><ThemeProvider
theme={defineTheme({
components: {
Checkbox: {
defaultProps: { variant: 'outline', shape: 'rounded' },
styleOverrides: {
root: 'gap-3',
control: 'shadow-xs',
label: 'font-medium',
},
},
},
})}
>
{children}
</ThemeProvider>Instance-level overrides win via tailwind-merge:
<Checkbox className="text-lg" sx={{ radius: 'lg' }} style={{ accentColor: '#0ea5e9' }}>
Accept
</Checkbox><Checkbox className="text-lg" sx={{ radius: 'lg' }} style={{ accentColor: '#0ea5e9' }}>
Accept
</Checkbox>RTL
labelPositionis logical —rightmeans end in LTR and start in RTL (mapped viaflex-row/flex-row-reverse).- The check and minus glyphs are centered in the box and need no flipping.
- Description text inherits the parent direction.
Do / Don't
- Do label every checkbox — verb-phrase children ("Subscribe", "Accept terms") read better
than nouns. Provide
aria-labelonly when the visual surface deliberately has no text. - Do use
indeterminatefor parent-of-children selection groups — the engine setsaria-checked="mixed"so screen readers announce the partial state correctly. - Don't disable a checkbox without telling the user why; pair
disabledwith a tooltip or helper text when feasible. - Don't use Checkbox for navigation or instant settings — that's
<Switch />'s job. Checkbox queues a value for form submission; Switch flips a setting now.