Base components
Checkbox
A checkbox with an optional label and supporting text, where the box fills with the brand color and the check mark draws itself in, and the mixed state shows a short line.
Live preview
Loading preview
A list of notification settings with a parent "All notifications" box that becomes mixed when only some children are checked, both sizes, a disabled row and an invalid "I accept the terms" box with the line "Check this box to continue." Replay unchecks every box and checks them again one by one.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add checkboxUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Checkbox } from './checkbox';
const STEP_MS = 400;
const CHILDREN = ['Comments', 'Mentions', 'Direct messages'];
// Comments, Mentions, Direct messages, Product updates, terms
const INITIAL = [true, false, true, true, false];
export default function CheckboxDemo({ replay = 0 }: { replay?: number }) {
const [on, setOn] = React.useState(INITIAL);
const set = (i: number) => (value: boolean) => setOn((list) => list.map((v, j) => (j === i ? value : v)));
const children = on.slice(0, 3);
const all = children.every(Boolean);
const some = children.some(Boolean);
React.useEffect(() => {
if (replay === 0) return;
setOn(INITIAL.map(() => false));
// Every box is checked again, one after the other.
const timers = INITIAL.map((_, i) =>
window.setTimeout(() => setOn((list) => list.map((v, j) => (j === i ? true : v))), (i + 1) * STEP_MS),
);
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay]);
return (
<div className="grid gap-x-12 gap-y-8 sm:grid-cols-2">
<div className="flex flex-col gap-3">
<Checkbox
label="All notifications"
checked={all}
indeterminate={some && !all}
onCheckedChange={() => setOn((list) => list.map((v, j) => (j < 3 ? !all : v)))}
/>
<div className="flex flex-col gap-3 ps-6">
{CHILDREN.map((label, i) => (
<Checkbox key={label} label={label} checked={on[i]} onCheckedChange={set(i)} />
))}
</div>
</div>
<div className="flex flex-col gap-4">
<Checkbox
size="md"
label="Product updates"
supportingText="News about features and changes."
checked={on[3]}
onCheckedChange={set(3)}
/>
<Checkbox size="md" label="Weekly summary" checked disabled />
<Checkbox
size="md"
label="I accept the terms"
supportingText={on[4] ? 'You can read the terms at any time.' : 'Check this box to continue.'}
invalid={!on[4]}
required
checked={on[4]}
onCheckedChange={set(4)}
/>
</div>
</div>
);
}
API
Parts: root (label element, the whole row is the tap area), control (box), check mark (stroked path), indeterminate line, text: label and supporting text.
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | undefined | Controlled checked state. |
| defaultChecked | boolean | false | Checked state when uncontrolled. |
| indeterminate | boolean | false | Shows the mixed state, for example a parent box when only some children are checked. |
| size | "sm" | "md" | "sm" | Box of 16 or 20px, with Text sm or Text md. |
| label | string | undefined | Label text. Without it, pass aria-label. |
| supportingText | string | undefined | Second line under the label. |
| invalid | boolean | false | Error border on an unchecked box and red supporting text, for a required box left unchecked (Figma Destructive). |
| disabled | boolean | false | Fades the control to 50%; the text stays at full opacity. |
| required | boolean | false | Native required for form validation. |
| name | string | undefined | Form field name. |
| value | string | "on" | Value sent with the form when checked. |
| Event | Description |
|---|---|
| checkedChange | The checked state changed. Payload: the new checked value. |
| Slot | Description |
|---|---|
| default | Label content, replaces the label prop. |
| supportingText | Supporting content, replaces the supportingText prop. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| CHK1 | hover an unchecked box (or its label) | control | background-color, border-color | bg-primary, border-control → bg-primary_hover, border-brand | duration-sm (200)ease-out | Swaps at once |
| CHK2 | checked -> true | control | background-color, border-color | bg-primary, border-control → bg-brand-solid, bg-brand-solid | duration-sm (200)ease-out | Swaps at once |
| CHK3 | checked -> true | check mark | stroke-dashoffset (draw from the short arm to the long arm) | path length → 0 | duration-md (260)ease-outdelay 60ms | Shows at once |
| CHK4 | checked -> false | check mark | opacity | 1 → 0 | duration-2xs (120)ease-exit | Hides at once |
| CHK5 | checked -> false | control | background-color, border-color | bg-brand-solid, bg-brand-solid → bg-primary, border-control | duration-xs (160)ease-out | Swaps at once |
| CHK6 | indeterminate -> true | indeterminate line | opacity, scale x | 0, scale-swap → 1, 1 | duration-sm (200)ease-out | Shows at once |
| CHK7 | indeterminate -> checked (or back) | indeterminate line and check mark | opacity (crossfade); the check mark then draws as CHK3 | old mark 1 → new mark 1 | duration-sm (200)ease-out | Swaps at once |
| CHK8 | press (pointer down or Space held) | control | scale | 1 → scale-press | duration-sm (200)ease-out | Stays still |
| CHK9 | keyboard focus | control | box-shadow | none → focus ring (focus-ring, or focus-ring-error when invalid) | duration-2xs (120)ease-out | Shows at once |
| CHK10 | invalid -> true | control border, supporting text | border-color, color | border-control, text-tertiary → border-error, text-error-primary | duration-sm (200)ease-out | Swaps at once |
Accessibility
- Built on a checkbox primitive: role checkbox with aria-checked true, false or mixed, Space toggles, and a hidden native input takes part in forms.
- The label and supporting text are linked with aria-labelledby and aria-describedby; clicking the text toggles the box.
- Without label text, the box is 16 or 20px: give it a 24 x 24px tap area or 4px of space around it (WCAG 2.5.8). With text, the whole row is the tap area.
- invalid sets aria-invalid; the supporting text explains what to do, not only the red color.
- Visible focus ring on keyboard focus only (focus-visible).
- Reduced motion: the box fills and the check mark shows at once, and nothing scales.