Checkbox
A square box that toggles a boolean. The checked box fills with the accent and draws a check in; the mixed state shows a minus. Built on Base UI's Checkbox, so Space toggles it, the hidden input integrates with forms, focus shows a visible ring, and the draw-in respects prefers-reduced-motion.
Basic
Pair a Checkbox.Label with a Checkbox.Root inside a Checkbox.Field: the label becomes the accessible name, clicking it toggles the box, and the whole row reads as one control. Checkbox.Root mounts its check glyph by default, so the common case needs no explicit Indicator. Drive it with checked / onCheckedChange, or omit them and pass defaultChecked to let it own its state.
"use client";
import { Checkbox } from "@stridge/noctis";
import { useState } from "react";
export default function CheckboxBasic() {
const [checked, setChecked] = useState(true);
return (
<Checkbox.Field>
<Checkbox.Root id="updates" checked={checked} onCheckedChange={setChecked} />
<Checkbox.Label>Email me about product updates</Checkbox.Label>
</Checkbox.Field>
);
}
Sizes
size sets the box and glyph metrics — sm, md (the default), or lg. Set it on the Checkbox.Field to size the box and scale its label together; the box inherits the field's size unless it sets its own.
"use client";
import { Checkbox } from "@stridge/noctis";
const SIZES = [
{ size: "sm", label: "Small" },
{ size: "md", label: "Medium" },
{ size: "lg", label: "Large" },
] as const;
export default function CheckboxSizes() {
return (
<div className="flex flex-col gap-3">
{SIZES.map(({ size, label }) => (
// One `size` on the field sizes the box and scales its label.
<Checkbox.Field key={size} size={size}>
<Checkbox.Root id={`size-${size}`} defaultChecked />
<Checkbox.Label>{label}</Checkbox.Label>
</Checkbox.Field>
))}
</div>
);
}
States
A checked box fills with the accent and draws the check in; an unchecked box rests as a bordered field box whose border turns to the field hover signal on hover. Read-only keeps the value visible but not editable; invalid paints the danger border; disabled dims through opacity and turns off its pointer affordance — disabled is never a recolour, and the component dims itself (no need to dim the row).
"use client";
import { Checkbox } from "@stridge/noctis";
// Each row pairs a Checkbox.Field with the props that drive one state. The component dims itself when
// disabled — the row no longer hand-stamps `opacity-disabled`, so disabled boxes are not double-dimmed.
const STATES = [
{ id: "st-checked", label: "Checked", props: { defaultChecked: true } },
{ id: "st-unchecked", label: "Unchecked", props: { defaultChecked: false } },
{ id: "st-indeterminate", label: "Indeterminate", props: { indeterminate: true } },
{ id: "st-disabled", label: "Disabled", props: { defaultChecked: true, disabled: true } },
{ id: "st-readonly", label: "Read-only", props: { defaultChecked: true, readOnly: true } },
{ id: "st-invalid", label: "Invalid", props: { "aria-invalid": true as const } },
] as const;
export default function CheckboxStates() {
return (
<div className="flex flex-col gap-3">
{STATES.map(({ id, label, props }) => (
<Checkbox.Field key={id}>
<Checkbox.Root id={id} {...props} />
<Checkbox.Label>{label}</Checkbox.Label>
</Checkbox.Field>
))}
</div>
);
}
Validation
A required acknowledgement validates at submit, not on blur. When invalid, the box shows the danger border and an error message is wired through aria-describedby — colour is never the only signal.
"use client";
import { Button, Checkbox } from "@stridge/noctis";
import { useState } from "react";
// A required acknowledgement validates at submit, not on blur (Geist). When invalid, the box shows the
// danger border and `aria-describedby` wires the error text — colour is never the only signal.
export default function CheckboxValidation() {
const [accepted, setAccepted] = useState(false);
const [submitted, setSubmitted] = useState(false);
const invalid = submitted && !accepted;
return (
<form
className="flex w-full max-w-sm flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
setSubmitted(true);
}}
>
<div className="flex items-center gap-6">
<Checkbox.Field>
<Checkbox.Root
id="terms"
required
checked={accepted}
onCheckedChange={setAccepted}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "terms-error" : undefined}
/>
<Checkbox.Label data-invalid={invalid || undefined}>I accept the terms</Checkbox.Label>
</Checkbox.Field>
<Button type="submit" size="sm">
Continue
</Button>
</div>
{invalid ? (
<p id="terms-error" className="text-small text-danger">
You must accept the terms to continue.
</p>
) : null}
</form>
);
}
Read-only
Read-only differs from disabled: it stays full-opacity and in the accessibility tree (the value is still announced), it simply can't be edited. Disabled dims the control and removes it from interaction.
"use client";
import { Checkbox } from "@stridge/noctis";
// Read-only differs from disabled: it stays full-opacity and in the a11y tree (the value is announced),
// it just can't be edited. Disabled dims and removes the affordance.
export default function CheckboxReadOnly() {
return (
<div className="flex flex-col gap-3">
<Checkbox.Field>
<Checkbox.Root id="ro-managed" readOnly defaultChecked />
<Checkbox.Label>Managed by your organization (read-only)</Checkbox.Label>
</Checkbox.Field>
<Checkbox.Field>
<Checkbox.Root id="ro-disabled" disabled defaultChecked />
<Checkbox.Label>Unavailable on your plan (disabled)</Checkbox.Label>
</Checkbox.Field>
</div>
);
}
Indeterminate & groups
A CheckboxGroup shares one ticked-value list across its children. Give the group allValues and add a parent checkbox to drive a "select all" affordance: the parent ticks when every child is on, clears when none are, and shows the mixed (indeterminate) minus glyph in between. Echo the mixed state with a partial count so it stays legible.
"use client";
import { Checkbox, CheckboxGroup } from "@stridge/noctis";
import { useState } from "react";
const ITEMS = ["Comments", "Mentions", "Follows"];
export default function CheckboxIndeterminate() {
const [value, setValue] = useState<string[]>(["Comments"]);
// A partial count keeps the mixed parent legible (Geist guidance) — "1 of 3 selected".
const count = value.length === 0 ? "None selected" : `${value.length} of ${ITEMS.length} selected`;
return (
<CheckboxGroup aria-label="Notifications" allValues={ITEMS} value={value} onValueChange={setValue}>
<Checkbox.Field className="font-medium">
<Checkbox.Root id="select-all" parent />
<Checkbox.Label>Select all</Checkbox.Label>
<span className="text-mini text-subtle">{count}</span>
</Checkbox.Field>
<div className="ms-6 flex flex-col gap-2.5">
{ITEMS.map((item) => (
<Checkbox.Field key={item}>
<Checkbox.Root id={item} name={item} />
<Checkbox.Label>{item}</Checkbox.Label>
</Checkbox.Field>
))}
</div>
</CheckboxGroup>
);
}
Orientation
A group stacks vertically by default; orientation="horizontal" lays it out in a row — useful for a compact filter bar.
"use client";
import { Checkbox, CheckboxGroup } from "@stridge/noctis";
import { useState } from "react";
// Slug ids keep the DOM/form values valid (no whitespace) while the label stays human-readable.
const FILTERS = [
{ id: "open", label: "Open" },
{ id: "in-review", label: "In review" },
{ id: "merged", label: "Merged" },
];
// `orientation="horizontal"` lays a group out in a row — handy for compact filter bars. The default is
// vertical. Each box is a single tab stop; Space toggles (arrow navigation belongs to RadioGroup).
export default function CheckboxOrientation() {
const [value, setValue] = useState<string[]>(["open", "in-review"]);
return (
<CheckboxGroup aria-label="Pull request filters" orientation="horizontal" value={value} onValueChange={setValue}>
{FILTERS.map(({ id, label }) => (
<Checkbox.Field key={id}>
<Checkbox.Root id={`filter-${id}`} name={id} />
<Checkbox.Label>{label}</Checkbox.Label>
</Checkbox.Field>
))}
</CheckboxGroup>
);
}
Checkbox card
A selectable card is a recipe, not a new part: a bordered Surface rendered as a <label> so the whole card is the toggle target, with the checkbox in the corner as the accessible control. Selecting it tints the border to the accent and washes the fill.
"use client";
import { Checkbox, Surface } from "@stridge/noctis";
import { useState } from "react";
const PLANS = [
{ id: "card-starter", title: "Starter", note: "1 project, community support." },
{ id: "card-pro", title: "Pro", note: "Unlimited projects and history." },
];
// A selectable card recipe (not a new part): a bordered Surface rendered as a `<label>`, so the whole
// card is the toggle target. Selecting it tints the border to the accent and washes the fill — the
// checkbox in the corner stays the accessible control.
export default function CheckboxCard() {
const [selected, setSelected] = useState<string[]>(["card-pro"]);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
{PLANS.map(({ id, title, note }) => {
const checked = selected.includes(id);
return (
<Surface
key={id}
as="label"
htmlFor={id}
bordered
className={`flex cursor-pointer items-start gap-3 p-4${checked ? " border-accent bg-accent-muted" : ""}`}
>
<Checkbox.Root
id={id}
checked={checked}
onCheckedChange={(next) =>
setSelected((prev) => (next ? [...prev, id] : prev.filter((value) => value !== id)))
}
/>
<span className="flex flex-col gap-1">
<span className="font-medium">{title}</span>
<span className="text-small text-subtle">{note}</span>
</span>
</Surface>
);
})}
</div>
);
}
Keyboard
| Key | Action |
|---|---|
| Tab | Move focus to the box. |
| Space | Toggle the focused box between checked and unchecked (a parent select-all cycles the group). |
A checkbox is a single tab stop — there is no arrow navigation. Arrow keys move between options in a RadioGroup, where only one option can be chosen.
Accessibility
- Role & state.
Checkbox.Rootrendersrole="checkbox"witharia-checkedreportingtrue/false/mixed, backed by a hidden<input>for forms. - Names. A
Checkbox.Labelinside aCheckbox.Fieldsupplies the accessible name. For a label-less box (e.g. a table-row select), pass an explicitaria-label. - Invalid. Set
aria-invalidand wire the message througharia-describedby(aField.Error-style line). The danger border is a reinforcement, never the only signal. - Read-only. Read-only boxes stay focusable and announced — they are reflected visually but never removed from the accessibility tree.
- Motion. The check draws in along the inline axis (inline-start in both LTR and RTL); under
prefers-reduced-motion: reduceit appears instantly, meaning preserved. - Touch & contrast. Every size carries a transparent hit area so the tap target clears the 24px minimum, and a forced-colors fallback keeps the check perceivable in Windows High Contrast.
Anatomy
Compose the checkbox from its parts. Checkbox.Root is a Base UI Checkbox, so toggling, the hidden form input, and the state attributes come for free.
Checkbox.Root— the box; owns the checked state (checked/onCheckedChange, or uncontrolleddefaultChecked), theindeterminateflag, the sharedsize, and form props (name,value,required,readOnly). Mounts a defaultIndicatorwhen given no children.Checkbox.Indicator— the glyph holder; draws the check in when ticked and the minus when indeterminate. Render it explicitly only to customise it.Checkbox.Field— the labelled row; links aCheckbox.Labelto the box (clicking the label toggles it), holds the box↔label gap, and cascades itssize.Checkbox.Label— the visible accessible name; mirrors the field's disabled and invalid state.CheckboxGroup— shares one selected-value list across a set of checkboxes (value/onValueChange, or uncontrolleddefaultValue); passallValuesto drive aparentcheckbox, andorientationto lay the group out in a row.
Every rendered part carries a data-slot (noctis-checkbox on the box, noctis-checkbox-indicator on the glyph holder, noctis-checkbox-field / noctis-checkbox-label on the labelled row, noctis-checkbox-group on the group) for host-side styling — pair it with the Base UI state attributes (data-checked, data-unchecked, data-indeterminate, data-disabled, data-readonly, data-invalid) and the group's data-orientation.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every checkbox in that region retunes — e.g. .dense { --noctis-checkbox-group-gap: 0.375rem; } tightens a group beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; the Root forwards the Base UI Checkbox props it owns toggling through. Expand a row for the full type and description.