Skip to content
Primitiv home
Framework
Consumption mode

Checkbox

stableSource Figma

A tri-state checkbox with an optional inline label — an independent on/off (or mixed) form selection.

Playground

Density

Preview

Size
import { Checkbox } from "@/components/ui/checkbox";
<Checkbox size="md">Email me product updates</Checkbox>

Density is set by a data-density ancestor — the Context system, not a Checkbox prop.

Installation

npx primitiv add checkbox

Import

import { Checkbox } from "@/components/ui/checkbox";

Copied into your project as .primitiv-checkbox — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<Checkbox>Label text</Checkbox>

Props

Checkbox.Root

Extends HTMLInputElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
checkedCheckedStateheadlessForbidden in uncontrolled mode — use defaultChecked instead. The current checked value, owned by the parent. May be "indeterminate" for the tri-state; clicking a mixed checkbox resolves it to true. Keep it in sync via onCheckedChange.
childrenReactNodeheadless
defaultCheckedCheckedStateheadlessChecked value on first render; the component owns it thereafter. May be "indeterminate" for a mixed-on-mount checkbox. Omit for an initially unchecked box. Forbidden in controlled mode — use checked instead.
onCheckedChange(checked: boolean) => voidheadlessFired with the new boolean checked value on every user toggle. Called with the new boolean checked value on every user toggle. Required in controlled mode so the parent can keep checked in sync.
refRef<HTMLInputElement>headlessAllows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying native <input type="checkbox">.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl size; data-density scales each size further.

Checkbox.Indicator

Headless only — the copied file renders this part for you and exports no separate component, so there is nothing to import under Styled.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, render children as the indicator element itself (via the Slot pattern) instead of wrapping them in a <span>. data-state and aria-hidden are merged onto that element.
childrenReactNodeheadlessCustom mark content. Omit to let the shipped CSS draw the tick/bar off the input's native :checked / :indeterminate state; provide your own (an icon, glyph, or nested element) to override it.

Styling contract

--primitiv-checkbox-bg--primitiv-checkbox-bg-checked--primitiv-checkbox-border-color--primitiv-checkbox-border-color-checked--primitiv-checkbox-border-width--primitiv-checkbox-mark-color--primitiv-checkbox-size--primitiv-checkbox-radius--primitiv-checkbox-mark-size--primitiv-checkbox-gap--primitiv-checkbox-label-color--primitiv-checkbox-label-font-family--primitiv-checkbox-label-font-size--primitiv-checkbox-label-font-weight--primitiv-checkbox-label-line-height

Data attributes

Checkbox

className: .primitiv-checkbox

AttributeValueWhen
data-statechecked | unchecked | indeterminatechecked / unchecked / indeterminate
data-disabled""disabled

Accessibility

  • It is a real <input type="checkbox">, visually hidden inside the <label> the Root renders — not a <div role="checkbox">. Space toggles it, forms submit it, and the label association is structural, so there is no htmlFor/id pair to keep in step.
  • The mixed state is the platform's. "indeterminate" is applied through the input's .indeterminate DOM property, which is what makes the browser announce aria-checked="mixed" — a class or a data- attribute alone would look mixed and read as unchecked.
  • onCheckedChange reports a boolean, never "indeterminate". A user cannot select the mixed state; only your code can set it, and clicking a mixed box resolves it to checked. Keep the derivation (all / none / some) in the parent.
  • The visual state keys off :checked and :indeterminate, not off data-state. That is what keeps a native form reset correct — the browser restores the input without a React render, and anything keyed off the mirror would be left behind. Use data-state for your own styling hooks, not as the source of truth.
  • Checkbox.Indicator is aria-hidden and always mounted; it carries no state of its own. Whatever mark you put in it is decoration — the announced state comes from the input, so a custom glyph never needs a label.
  • disabled sets the native attribute and publishes data-disabled, so the control leaves the tab order and form submission because the platform says so. Prefer it to a read-only look-alike, and keep the reason visible in nearby text — a disabled control announces nothing about why.
  • For a card-sized target with a description inside it, use CheckboxCard; for one-of-many, Radio. A checkbox is for an independent yes/no, and a set of checkboxes where exactly one may be chosen is the classic misuse.

Examples

Labelling

The children are the label, and there is no htmlFor to wire: the Root renders a real <label> with the input inside it, so the association is structural and cannot come apart. Reach for Field when you need more than a label — a description or an error message — and let it own the aria-describedby. The one thing not to do is put the visible text outside and leave the checkbox unlabelled.

Density
Helps us work out which features to keep.
import { Checkbox } from "@/components/ui/checkbox";import { Field, FieldDescription } from "@/components/ui/field";
<Checkbox>Email me product updates</Checkbox>
<Field>  <Checkbox>Share anonymous usage data</Checkbox>  <FieldDescription>    Helps us work out which features to keep.  </FieldDescription></Field>

Indeterminate (the tri-state)

Pass checked="indeterminate" for the mixed state — a parent whose children disagree. It is the platform's indeterminate, set through the input's .indeterminate DOM property rather than a class, so the browser exposes aria-checked="mixed" and the :indeterminate pseudo-class for free. Two asymmetries to plan for: onCheckedChange always hands you a boolean, never "indeterminate", and clicking a mixed box resolves it to checked.

Density
import { Checkbox } from "@/components/ui/checkbox";import { Stack } from "@/components/ui/stack";
const LABELS = ["Comments", "Mentions", "Weekly digest"];
const [items, setItems] = useState([true, false, false]);const all = items.every(Boolean);const none = items.every((c) => !c);
<Stack gap="sm">  <Checkbox    checked={all ? true : none ? false : "indeterminate"}    onCheckedChange={(checked) => setItems(items.map(() => checked))}  >    Notifications  </Checkbox>
  <Stack gap="sm" style={{ paddingInlineStart: "1.75rem" }}>    {LABELS.map((label, i) => (      <Checkbox        key={label}        checked={items[i]}        onCheckedChange={(checked) =>          setItems(items.map((c, j) => (j === i ? checked : c)))        }      >        {label}      </Checkbox>    ))}  </Stack></Stack>

In a form

Every native attribute lands on the real <input>name, value, required, form — so the box submits and validates like any checkbox, with no adapter. It is also why the stylesheet keys its visual states off :checked and :indeterminate rather than off data-state: press Reset below and the browser restores the input without telling React, and a data-state mirror would be left painting the old state. data-state is a convenience hook for your own CSS, not the source of truth.

Density
import { Checkbox } from "@/components/ui/checkbox";import { Stack } from "@/components/ui/stack";import { Button } from "@/components/ui/button";
<form action="/subscribe" method="post">  <Checkbox name="updates" value="yes" defaultChecked>Email me product updates</Checkbox>  <Checkbox name="terms" value="accepted" required>I accept the terms</Checkbox>
  <Stack direction="row" gap="sm">    <Button type="submit" size="sm">Submit</Button>    <Button type="reset" variant="secondary" size="sm">Reset</Button>  </Stack></form>

Sizes and disabled

Five sizes, each rescaling again with the nearest data-density ancestor — the box, the mark, the gap and the label type all move together. disabled sets the native attribute and publishes data-disabled, so the hook and the behaviour cannot drift: the platform takes it out of the tab order and out of form submission, rather than CSS making it look inert.

Density
import { Checkbox } from "@/components/ui/checkbox";
<div data-density="comfortable">  <Checkbox size="xs">xs</Checkbox>  <Checkbox size="sm">sm</Checkbox>  <Checkbox size="md">md</Checkbox>  <Checkbox size="lg">lg</Checkbox>  <Checkbox size="xl">xl</Checkbox>
  <Checkbox disabled>Unavailable</Checkbox>  <Checkbox disabled defaultChecked>Locked on</Checkbox></div>

Customising the mark

The two surfaces answer this differently, which is the clearest illustration of what the mode switch buys you. The copied file draws the tick in CSS, so you retune it with the custom properties it publishes — set below on the element for brevity, though a stylesheet is where they belong. In headless there is no CSS to retune: Checkbox.Indicator takes children, so you pass whatever mark you want (and asChild if it should BE your element rather than sit in a <span>). It is always mounted and aria-hidden in both, because the accessible state lives on the input.

Density
import { Checkbox } from "@/components/ui/checkbox";
<Checkbox  defaultChecked  style={{    "--primitiv-checkbox-mark-color": "var(--primitiv-content-primary)",    "--primitiv-checkbox-bg-checked": "var(--primitiv-surface-sunken)",    "--primitiv-checkbox-border-color-checked":      "var(--primitiv-border-default)",  }}>  Custom mark</Checkbox>