Component · Core
Checkbox
Overview
A binary control for a single independent choice, or one item in a set where a user may pick zero, one, or several. Checkbox shares its supporting-text and validation rules with Input's shared form-field model, but its label sits beside the control, not above it — see Label placement.
Anatomy
Box (--scale-4, --radius-sm) → checkmark icon (checked) or minus icon (indeterminate) →
label, to the right.
- Box — the control surface.
- Checkmark icon — shown when checked.
- Minus icon — shown when indeterminate.
- Label — sits to the right of the box.
Live Example
States
Required, with error
You must accept the terms to continue.
Group
Code Example
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
const GROUP_OPTIONS = [
{ id: "notify-comments", label: "Comments on my posts" },
{ id: "notify-mentions", label: "Mentions" },
{ id: "notify-digest", label: "Weekly digest" },
] as const;
/**
* Live Checkbox example — the label sits beside the box (`--scale-2` gap), and
* any supporting message aligns beneath the start of the label text (`pl-6` =
* box + gap), not beneath the box. Shows the checked, indeterminate, disabled,
* and error states, plus a `<fieldset>` / `<legend>` group with `--scale-2`
* between option rows and group-level helper text.
*
* Checkbox keeps a component-specific `--destructive` error-text treatment
* (system-wide alignment with Input's `--foreground` contract is deferred).
*/
export function CheckboxShowcase() {
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
States
</h3>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox id="cb-unchecked" />
<Label htmlFor="cb-unchecked">Unchecked</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="cb-checked" defaultChecked />
<Label htmlFor="cb-checked">Checked</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="cb-indeterminate" indeterminate />
<Label htmlFor="cb-indeterminate">Indeterminate (select all)</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="cb-disabled" disabled />
<Label htmlFor="cb-disabled" className="opacity-50">
Disabled
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="cb-disabled-checked" disabled defaultChecked />
<Label htmlFor="cb-disabled-checked" className="opacity-50">
Disabled, checked
</Label>
</div>
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Required, with error
</h3>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Checkbox
id="cb-terms"
required
aria-invalid="true"
aria-describedby="cb-terms-error"
/>
<Label htmlFor="cb-terms">I agree to the terms</Label>
</div>
<p
id="cb-terms-error"
className="pl-6 text-xs leading-snug text-destructive"
>
You must accept the terms to continue.
</p>
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Group
</h3>
<fieldset className="min-w-0" aria-describedby="cb-group-helper">
<legend className="text-sm font-medium leading-snug text-foreground">
Notify me about
</legend>
<div className="mt-2 flex flex-col gap-2">
{GROUP_OPTIONS.map((option) => (
<div key={option.id} className="flex items-center gap-2">
<Checkbox
id={option.id}
defaultChecked={option.id === "notify-mentions"}
/>
<Label htmlFor={option.id}>{option.label}</Label>
</div>
))}
</div>
<p
id="cb-group-helper"
className="mt-1.5 text-xs leading-snug text-muted-foreground"
>
You can change these any time in settings.
</p>
</fieldset>
</section>
</div>
);
}Variants
None — Checkbox has a single visual treatment. States (below) carry all necessary variation.
Label placement
Box on the left, label on the right, one row. Unlike the shared form-field model's label-above-control stack (Input, Textarea), a Checkbox's label sits beside its control — the established convention for a control small enough to read as one unit with its label. Multi-line labels wrap aligned to the top of the box.
Checkbox group
A layout pattern, not a separate component: related Checkboxes wrapped in a <fieldset> with a
visible <legend>, stacked with --scale-2 (8px) between option rows — tighter than the
--scale-6 gap between separate form fields, since these options are one semantic unit, not
independent fields.
- Group-level helper text — allowed, same Caption typography
and
--muted-foregroundcolor as Input, placed below the full list of options. - Group-level error — allowed, replaces the group helper text, referenced via
aria-describedbyon the fieldset. It shares the helper text's Caption typography — only the color differs. Checkbox currently retains a component-specific error-text treatment using--destructive; it does not yet match Input's canonical--foregrounderror-message contract. That color difference may be reconciled in a future release; the typography is already aligned. - Per-option disabled — any individual Checkbox in a group can be
disabledindependently; it doesn't require disabling the whole group.
States
| State | Look |
|---|---|
| Default | --background fill, --input border |
| Hover | Border shifts from --input to --foreground — a stronger, higher-contrast border, not a fill. Deliberately not --accent, which reads too close to the checked fill. |
| Focus-visible | --ring on :focus-visible (keyboard focus only), flush against the box edge, tracing --radius-sm — the field focus-ring geometry (Input, Color) |
| Checked | --primary fill, --primary-foreground checkmark |
| Unchecked | Default, as above |
| Indeterminate | --primary fill, --primary-foreground minus icon — same treatment as Checked, different icon |
| Disabled | Reduced opacity (opacity-50; Color — Disabled dimming), native disabled attribute, no pointer events |
| Error | --destructive border |
Indeterminate state
Used for a "select all" parent Checkbox when only some of its children are checked.
Native <input type="checkbox">: set the DOM indeterminate property (checkbox.indeterminate = true)
in script. It's presentation-only, not an HTML attribute — the browser renders the :indeterminate
pseudo-class and already exposes the mixed state to assistive tech on its own. Don't also set
aria-checked="mixed" on a native input — it's redundant, the same relationship required has
with aria-required on Input.
Custom (non-native) implementation: only when native semantics genuinely aren't available
(e.g. a fully custom control not built on <input>) does aria-checked="mixed" need to be set
and managed by hand.
The logic that computes a parent's indeterminate state from its children's checked state ("select all") is a pattern to apply, not something this baseline builds — it's application logic, not a new anatomy or token.
Required validation
A required Checkbox (most often consent, e.g. "I agree to the Terms") uses the native required
attribute. Its error state (see States) follows the same rule as Input: never rely on
the red border alone — always pair it with visible error text.
Usage Guidance
Single checkbox vs. checkbox group
| Use case | Behavior |
|---|---|
| Single independent choice | One Checkbox, one <label>, no group semantics ("Remember me", "Enable dark mode"). |
| Zero, one, or several from a set | Multiple Checkboxes — see Checkbox group below. |
| Agreement / consent | A single required Checkbox — see Required validation. |
Not every multi-checkbox layout needs a formal group wrapper — a couple of unrelated toggles on a settings page can just be two standalone Checkboxes. Reach for a group when the options are genuinely one set the user is choosing from.
Content guidance
-
Label reads as a full clickable phrase, not just a noun — "Remember me on this device", "I agree to the Terms".
-
The label holds the checkbox's own selectable wording only. Secondary interactive content — a "Terms," "Privacy Policy," or "Learn more" link — sits adjacent to the label, outside the
<label>element, not nested inside it:<input id="accept-terms" type="checkbox"> <label for="accept-terms">I agree to the terms.</label> <a href="/terms">Read the Terms of Service</a>Nesting the link inside the
<label>isn't wrong because clicking it would "also toggle the checkbox" — it wouldn't, browsers already treat a click on a nested interactive element as activating that element, not the label. It's wrong because it creates two competing interactive targets inside one clickable area, shrinks the label's own dependable activation region (a click meant for the checkbox can land on the link instead), and complicates the accessible name and interaction expectations a screen reader or other assistive tech builds around the label. Keeping the link as a sibling, explicitly associated to its own checkbox viaid/for, avoids all three problems while keeping the checkbox-and-link relationship visually clear. -
Sentence case, no trailing colon.
-
Keep group legends short — a question or category name ("Notify me about").
Tokens
Checkbox uses existing Monogem semantic and primitive tokens to stay consistent with the design system; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--primary / --primary-foreground | Checked and indeterminate fill + icon |
--background | Unchecked fill |
--input | Default border |
--foreground | Hover border |
--ring | Focus-visible ring |
--destructive | Error border, error text |
--muted-foreground | Helper text |
--radius-sm | Box corner radius — Radius already names "tags, inputs" for this token |
--scale-4 | Box size (16px) — Icons's "inline with text" size; the checkmark/minus icon fills the box at this same size, no separate icon-size token needed |
--scale-2 | Box-to-label gap; gap between stacked option rows in a group |
--scale-1-5 | Control-to-message gap, same value as Input |
Do / Don’t
Do
- Pair every Checkbox with a visible label via native
htmlFor/id. - Use a group (
fieldset+legend) when options genuinely belong to one set. - Use indeterminate only for a "select all" parent whose children are partially checked.
Don’t
- Use a strong
--accentfill for hover — it reads too close to the checked state. - Set
aria-checked="mixed"on a native<input type="checkbox">— theindeterminateDOM property already covers it. - Reach for Checkbox when only one option may ever be selected — that's Radio Group.
- Reach for Checkbox for an immediate system setting — that's Switch.
- Treat indeterminate as a true third stored value — it's a transient visual state, not a persisted tri-state.
Accessibility
- Native
<input type="checkbox">, always paired with a<label>viahtmlFor/id— see Label. - Helper or error text aligns beneath the start of the label text, not beneath the box — the label is the primary reading content, so supporting text reads as continuing it.
aria-describedbyreferences every currently visible helper/error message id, same rule as Input.aria-invalid="true"only while actually invalid.- Checked state is never color-only — the checkmark/minus icon shape is the primary signal, color reinforces it.
- Native
disabledremoves the control from the tab order automatically.
Related Components / Patterns
- Radio Group — exactly one choice from a visible list.
- Switch — an immediate on/off setting.