A tri-state checkbox with an optional inline label — an independent on/off (or mixed) form selection.
Playground
Preview
import{Checkbox}from"@/components/ui/checkbox";<Checkboxsize="md">Email me product updates</Checkbox>
import{Checkbox}from"@primitiv-ui/react";<Checkbox.Root><Checkbox.Indicator/> Email me product updates</Checkbox.Root>
Density is set by a data-density ancestor — the Context system, not a Checkbox prop.
Installation
npx primitiv add checkbox
pnpm dlx primitiv add checkbox
yarn dlx primitiv add checkbox
bunx 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
The one component whose two surfaces have a different shape, not just different names. @primitiv-ui/react exports Root and Indicator and leaves you to place them; the copied file exports a single Checkbox that renders the box, the mark and the label span itself — so there is no CheckboxIndicator to import under Styled. Either way the Root renders a real <label> wrapping a visually-hidden <input type="checkbox">.
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Checkbox.Root
Extends HTMLInputElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
checked
CheckedState
—
headless
Forbidden 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.
children
ReactNode
—
headless
defaultChecked
CheckedState
—
headless
Checked 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) => void
—
headless
Fired 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.
ref
Ref<HTMLInputElement>
—
headless
Allows 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"
md
styled
Control 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When 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.
children
ReactNode
—
headless
Custom 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
15 CSS custom properties on .primitiv-checkbox — mode-agnostic. These names are the stable surface; the values are not.
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
Checkbox
className:.primitiv-checkbox
Attribute
Value
When
data-state
checked | unchecked | indeterminate
checked / unchecked / indeterminate
data-disabled
""
disabled
Checkbox.Root
Attribute
Value
When
data-state
checked | unchecked | indeterminate
checked / 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
Every example below reacts to the density control. Currently showing Styled mode.
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.
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>
import{Checkbox}from"@primitiv-ui/react";import{Field}from"@primitiv-ui/react";<Checkbox.Root><Checkbox.Indicator/> Email me product updates</Checkbox.Root><Field><Checkbox.Root><Checkbox.Indicator/> Share anonymous usage data</Checkbox.Root><Field.Description> Helps us work out which features to keep.</Field.Description></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.
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.
import{Checkbox}from"@/components/ui/checkbox";import{Stack}from"@/components/ui/stack";import{Button}from"@/components/ui/button";<formaction="/subscribe"method="post"><Checkboxname="updates"value="yes"defaultChecked>Email me product updates</Checkbox><Checkboxname="terms"value="accepted"required>I accept the terms</Checkbox><Stackdirection="row"gap="sm"><Buttontype="submit"size="sm">Submit</Button><Buttontype="reset"variant="secondary"size="sm">Reset</Button></Stack></form>
import{Checkbox}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";import{Button}from"@primitiv-ui/react";<formaction="/subscribe"method="post"><Checkbox.Rootname="updates"value="yes"defaultChecked><Checkbox.Indicator/> Email me product updates</Checkbox.Root><Checkbox.Rootname="terms"value="accepted"required><Checkbox.Indicator/> I accept the terms</Checkbox.Root><Stackdirection="row"gap="sm"><Buttontype="submit"size="sm">Submit</Button><Buttontype="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.
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.