A card/tile-shaped checkbox — the whole bordered surface is the interactive element, not a small control plus a separate label. Composes the headless CheckboxCard primitive (role="checkbox", tri-state unchecked/checked/indeterminate). Sized xs–xl; data-density scales each size further.
Playground
Preview
import{CheckboxCard}from"@/components/ui/checkbox-card";<CheckboxCardsize="md"defaultCheckedshowDescription={true}title="Analytics"description="Page views, referrers and conversion funnels."/>
import{CheckboxCard}from"@primitiv-ui/react";<CheckboxCard.RootdefaultChecked><CheckboxCard.IndicatorforceMount/><span>Analytics</span><span>Page views, referrers and conversion funnels.</span></CheckboxCard.Root>
Density is set by a data-density ancestor — the Context system, not a CheckboxCard prop.
Copied into your project as .primitiv-checkbox-card — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
The widest gap between the two surfaces of any choice control, and it is a real API difference rather than a rename. @primitiv-ui/react gives you Root and Indicator and leaves the content to children; the copied file exports a single CheckboxCard that takes title and description as props and renders the indicator, the title and the description spans itself. Those three props exist nowhere in @primitiv-ui/react — so switching this page to Headless removes them from the props table, correctly.
<CheckboxCardtitle="Analytics"description="Page views and referrers."/>
<CheckboxCard.Root><CheckboxCard.IndicatorforceMount/><span>Analytics</span><span>Page views and referrers.</span></CheckboxCard.Root>
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
CheckboxCard.Root
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
The card's heading. Always present — the required content.
checked
CheckedState
—
headless
Forbidden in uncontrolled mode — use defaultChecked instead.
The controlled tri-state checked value (true / false /
"indeterminate"). Must be kept in sync by the parent via
onCheckedChange.
defaultChecked
CheckedState
false
headless
Tri-state checked value on first render (true / false /
"indeterminate"). Defaults to false when omitted.
Forbidden in controlled mode — use checked instead.
description
ReactNode
—
headless
The card's supporting text, shown below the title.
onCheckedChange
(checked: boolean) => void
—
headless
Called with the new boolean checked state whenever the card toggles.
An indeterminate card resolves to true on first click. Optional in
uncontrolled mode.
Called with the new boolean checked state when the user toggles. An
indeterminate card resolves to true on click. Required in controlled
mode.
ref
Ref<HTMLButtonElement>
—
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 rendered element (the <button> by default).
showDescription
boolean
true
headless
Shows or hides description without unmounting the title — a
separate, skippable subcomponent, not a change to the anatomy.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Card size; data-density scales each size further.
CheckboxCard.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 the consumer's own element (typically an <svg>) as
the indicator instead of the default <span>, merging aria-hidden and
data-state onto it via the Slot pattern.
children
ReactNode
—
headless
The indicator content — typically an <svg> tick or check glyph.
forceMount
boolean
false
headless
Keep the indicator mounted while the card is unchecked so a CSS exit
animation can play against data-state="unchecked". By default the
indicator unmounts as soon as the card is unchecked.
Styling contract
30 CSS custom properties on .primitiv-checkbox-card — mode-agnostic. These names are the stable surface; the values are not.
All native, because the root is a real <button> — the component adds no key handling of its own. Note the difference from RadioCard: every card here is its own tab stop, since there is no group to rove within.
Key
Behaviour
Tab
Move focus to the card. Each card is a separate tab stop; disabled cards are skipped.
Space
Toggle the card. An "indeterminate" card resolves to checked, never back to unchecked.
Enter
Also toggles — a native <button> activates on both keys. Worth knowing if the card sits in a form, because Enter on a real checkbox would submit instead.
Data attributes
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.
CheckboxCard
className:.primitiv-checkbox-card
Attribute
Value
When
data-state
checked | unchecked | indeterminate
checked / unchecked / indeterminate
data-disabled
""
disabled
CheckboxCard.Root
Attribute
Value
When
data-state
checked | unchecked | indeterminate
checked / unchecked / indeterminate
data-disabled
""
disabled
CheckboxCard.Indicator
Headless only — the copied file renders this part for you and exports no separate component, so there is nothing to import under Styled. Target it with the class below.
className:.primitiv-checkbox-card__mark
Attribute
Value
When
data-state
checked | unchecked | indeterminate
the card's checked state
CheckboxCard.Indicator
Attribute
Value
When
data-state
checked | unchecked | indeterminate
the card's checked state
Accessibility
Checkbox or CheckboxCard?Checkbox is a real <input type="checkbox"> in a <label>: it submits with a form, takes a name, and is the right choice inside one. CheckboxCard is a <button role="checkbox"> — no input, no name, nothing in FormData — for when the option is a substantial choice with supporting text that deserves a whole surface. Choose on form participation first, not on looks.
The card's accessible name comes from its content, so it includes the description as well as the title. That is usually what you want for a card whose description is part of the choice; when the description is long, or is redundant boilerplate, pass an explicit aria-label so the announcement stays the option's name.
aria-checked carries the state, including "mixed" for indeterminate — that is the ARIA spelling, and it is what a screen reader announces as "partially checked". data-state is the CSS mirror of the same thing (checked / unchecked / indeterminate); style off data-state, never off aria-checked.
There is no :checked pseudo-class to hook, because there is no input. The shipped stylesheet keys everything off data-state on the root, and unlike Checkbox — where data-state can lag the browser's own silent changes — here React owns the state outright, so data-state is always accurate.
The indicator is aria-hidden and carries no state of its own; it is decoration over aria-checked. forceMount (which the copied file always passes) only keeps it in the DOM so a CSS exit animation can play — it changes nothing in the accessibility tree.
A group of cards still needs a group label. Nothing here is a radiogroup or a fieldset, so a set of related cards should sit inside a Field with a <legend>, or a container with role="group" and an aria-labelledby — otherwise the options are announced with no idea what they belong to.
The whole card is the hit target, which is the point — but it also means anything interactive inside a card is a nested control inside a button, which is invalid. Keep links and buttons out of the card; put them beside it.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
It is a button, not an input (the headline)
CheckboxCard renders a <button type="button" role="checkbox">. There is no hidden <input> anywhere in it, which has one consequence worth knowing before you choose it: it does not submit with a form, and it takes no name. Read its state from React and post it yourself, or use Checkbox — a real <input type="checkbox"> in a <label> — when the browser's own form handling is what you want. Everything else follows from the same fact: the accessible state is aria-checked, not :checked, and the whole bordered surface is the hit target rather than a small box plus a label.
import{CheckboxCard}from"@/components/ui/checkbox-card";// A button, so no name and nothing in FormData — you own the value.<CheckboxCardtitle="Analytics"description="Page views, referrers and conversion funnels."/>
import{CheckboxCard}from"@primitiv-ui/react";// A button, so no name and nothing in FormData — you own the value.<CheckboxCard.Root><CheckboxCard.IndicatorforceMount/><span>Analytics</span><span>Page views, referrers and conversion funnels.</span></CheckboxCard.Root>
Each card is independent
Unlike RadioCard, there is no group: each card owns its own state and nothing coordinates them. That is what makes this the multi-select control — three cards mean three independent answers, and turning one on never turns another off. Layout is deliberately not baked in either; stack them, put them in a row, or drop them in a grid, and the cards fill whatever you compose them into.
import{CheckboxCard}from"@/components/ui/checkbox-card";import{Stack}from"@/components/ui/stack";<Stackgap="sm"><CheckboxCardtitle="Analytics"description="Page views, referrers and conversion funnels."/><CheckboxCardtitle="Alerts"description="Email or Slack when a threshold is crossed."/><CheckboxCardtitle="Exports"description="Scheduled CSV delivery to your warehouse."/></Stack>
import{CheckboxCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<Stackgap="sm"><CheckboxCard.Root><CheckboxCard.IndicatorforceMount/><span>Analytics</span><span>Page views, referrers and conversion funnels.</span></CheckboxCard.Root><CheckboxCard.Root><CheckboxCard.IndicatorforceMount/><span>Alerts</span><span>Email or Slack when a threshold is crossed.</span></CheckboxCard.Root><CheckboxCard.Root><CheckboxCard.IndicatorforceMount/><span>Exports</span><span>Scheduled CSV delivery to your warehouse.</span></CheckboxCard.Root></Stack>
Indeterminate and select-all
checked is tri-state — true, false or "indeterminate" — which is what makes the select-all pattern possible: the parent is indeterminate exactly when some but not all children are on, a state no boolean can hold. Two shapes to note. onCheckedChange is always called with a boolean, never with "indeterminate", because an indeterminate card resolves to true on click — so the parent's handler is a plain select-all/clear-all. And the styling treats indeterminate as selected: the card fills the same way a checked one does, and only the mark differs (a bar instead of a tick). The indentation here is the page's own CSS — the primitive does not bake in a hierarchy.
import{ useState }from"react";import{CheckboxCard}from"@/components/ui/checkbox-card";import{Stack}from"@/components/ui/stack";const[on, setOn]=useState(["analytics"]);const all = on.length===FEATURES.length;<Stackgap="sm"><CheckboxCardchecked={all ?true: on.length===0?false:"indeterminate"}onCheckedChange={(next)=>setOn(next ?FEATURES.map((f)=> f.value):[])}title="All features"description="${on.length} of 3 selected"/><CheckboxCardsize="sm"checked={on.includes("analytics")}onCheckedChange={...}title="Analytics"/><CheckboxCardsize="sm"checked={on.includes("alerts")}onCheckedChange={...}title="Alerts"/><CheckboxCardsize="sm"checked={on.includes("exports")}onCheckedChange={...}title="Exports"/></Stack>
import{ useState }from"react";import{CheckboxCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";const[on, setOn]=useState(["analytics"]);const all = on.length===FEATURES.length;<Stackgap="sm"><CheckboxCard.Rootchecked={all ?true: on.length===0?false:"indeterminate"}onCheckedChange={(next)=>setOn(next ?FEATURES.map((f)=> f.value):[])}><CheckboxCard.IndicatorforceMount/><span>All features</span><span>${on.length} of 3 selected</span></CheckboxCard.Root><CheckboxCard.Rootchecked={on.includes("analytics")}onCheckedChange={...}><CheckboxCard.IndicatorforceMount/><span>Analytics</span></CheckboxCard.Root><CheckboxCard.Rootchecked={on.includes("alerts")}onCheckedChange={...}><CheckboxCard.IndicatorforceMount/><span>Alerts</span></CheckboxCard.Root><CheckboxCard.Rootchecked={on.includes("exports")}onCheckedChange={...}><CheckboxCard.IndicatorforceMount/><span>Exports</span></CheckboxCard.Root></Stack>
Controlled
Pass checked and onCheckedChange together and you own the value. Both are required together in controlled mode — the props table flattens the two shapes into one list, but TypeScript accepts either checked + onCheckedChange or defaultChecked, never a mix. Since there is no form to submit into, controlled is the normal way to use these cards: the value has to reach your code somehow.
Enabled: analytics
import{ useState }from"react";import{CheckboxCard}from"@/components/ui/checkbox-card";const[on, setOn]=useState(["analytics"]);<CheckboxCardchecked={on.includes("analytics")}onCheckedChange={()=>toggle("analytics")}title="Analytics"description="Page views, referrers and conversion funnels."/>
import{ useState }from"react";import{CheckboxCard}from"@primitiv-ui/react";const[on, setOn]=useState(["analytics"]);<CheckboxCard.Rootchecked={on.includes("analytics")}onCheckedChange={()=>toggle("analytics")}><CheckboxCard.IndicatorforceMount/><span>Analytics</span><span>Page views, referrers and conversion funnels.</span></CheckboxCard.Root>
Title only
description is optional, and showDescription toggles it without unmounting the title — useful when the supporting text is conditional and you do not want the card's identity to change with it. Note that showDescription only has an effect when there is a description to show: the copied file renders the description only when both are truthy, so showDescription on a card with no description does nothing rather than reserving empty space.
import{CheckboxCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<Stackgap="sm"><CheckboxCard.Root><CheckboxCard.IndicatorforceMount/><span>Analytics</span></CheckboxCard.Root><CheckboxCard.Root><CheckboxCard.IndicatorforceMount/><span>Alerts</span><span>Hidden by showDescription={false}.</span></CheckboxCard.Root></Stack>
Sizes and density
Five sizes, each rescaling again with the nearest data-density ancestor. Size moves the whole card — padding, radius, the indicator, and both text ramps — so the smaller sizes are for dense option lists rather than for squeezing a long description into a small card.
disabled forwards the native <button> attribute, so the card cannot be toggled and leaves the tab order; data-disabled lands on the root for styling. A disabled card keeps its checked look — data-state is independent of data-disabled, which is what lets you show a locked-on option rather than an option that merely looks unavailable.
import{CheckboxCard}from"@/components/ui/checkbox-card";import{Stack}from"@/components/ui/stack";<Stackgap="sm"><CheckboxCarddefaultCheckeddisabledtitle="Analytics"description="Included in every plan."/><CheckboxCarddisabledtitle="Exports"description="Available on the Team plan."/></Stack>
import{CheckboxCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<Stackgap="sm"><CheckboxCard.RootdefaultCheckeddisabled><CheckboxCard.IndicatorforceMount/><span>Analytics</span><span>Included in every plan.</span></CheckboxCard.Root><CheckboxCard.Rootdisabled><CheckboxCard.IndicatorforceMount/><span>Exports</span><span>Available on the Team plan.</span></CheckboxCard.Root></Stack>