Skip to content
Primitiv home
Framework
Consumption mode

Checkbox Card

stableSource Figma

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

Density

Preview

Size
import { CheckboxCard } from "@/components/ui/checkbox-card";
<CheckboxCard size="md" defaultChecked showDescription={true} title="Analytics" description="Page views, referrers and conversion funnels." />

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

Installation

npx primitiv add checkbox-card

Import

import { CheckboxCard } from "@/components/ui/checkbox-card";

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

<CheckboxCard title="Analytics" description="Page views and referrers." />

Props

CheckboxCard.Root

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

PropTypeDefaultFromDescription
title (required)string & ReactElement<unknown, string | JSXElementConstructor<any>> | string & Iterable<ReactNode> | string & ReactPortal | string & Promise<AwaitedReactNode> | undefinedheadlessThe card's heading. Always present — the required content.
checkedCheckedStateheadlessForbidden 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.
defaultCheckedCheckedStatefalseheadlessTri-state checked value on first render (true / false / "indeterminate"). Defaults to false when omitted. Forbidden in controlled mode — use checked instead.
descriptionReactNodeheadlessThe card's supporting text, shown below the title.
onCheckedChange(checked: boolean) => voidheadlessCalled 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.
refRef<HTMLButtonElement>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 rendered element (the <button> by default).
showDescriptionbooleantrueheadlessShows or hides description without unmounting the title — a separate, skippable subcomponent, not a change to the anatomy.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledCard 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen 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.
childrenReactNodeheadlessThe indicator content — typically an <svg> tick or check glyph.
forceMountbooleanfalseheadlessKeep 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

Control frame

--primitiv-checkbox-card-bg--primitiv-checkbox-card-bg-selected--primitiv-checkbox-card-border-color--primitiv-checkbox-card-border-color-hover--primitiv-checkbox-card-border-color-selected--primitiv-checkbox-card-border-width--primitiv-checkbox-card-radius--primitiv-checkbox-card-padding--primitiv-checkbox-card-gap--primitiv-checkbox-card-shadow-hover--primitiv-checkbox-card-indicator-size--primitiv-checkbox-card-indicator-radius--primitiv-checkbox-card-indicator-bg--primitiv-checkbox-card-indicator-bg-selected--primitiv-checkbox-card-indicator-border-color--primitiv-checkbox-card-indicator-mark-size--primitiv-checkbox-card-indicator-mark-color--primitiv-checkbox-card-title-color--primitiv-checkbox-card-title-color-selected--primitiv-checkbox-card-title-font-family--primitiv-checkbox-card-title-font-size--primitiv-checkbox-card-title-font-weight--primitiv-checkbox-card-title-line-height--primitiv-checkbox-card-description-color--primitiv-checkbox-card-description-color-selected--primitiv-checkbox-card-description-font-family--primitiv-checkbox-card-description-font-size--primitiv-checkbox-card-description-font-weight--primitiv-checkbox-card-description-line-height

Panel

--primitiv-checkbox-card-content-gap

Keyboard

KeyBehaviour
TabMove focus to the card. Each card is a separate tab stop; disabled cards are skipped.
SpaceToggle the card. An "indeterminate" card resolves to checked, never back to unchecked.
EnterAlso 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

CheckboxCard

className: .primitiv-checkbox-card

AttributeValueWhen
data-statechecked | unchecked | indeterminatechecked / 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

AttributeValueWhen
data-statechecked | unchecked | indeterminatethe 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

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.

Density
import { CheckboxCard } from "@/components/ui/checkbox-card";
// A button, so no name and nothing in FormData — you own the value.<CheckboxCard title="Analytics" description="Page views, referrers and conversion funnels." />

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.

Density
import { CheckboxCard } from "@/components/ui/checkbox-card";import { Stack } from "@/components/ui/stack";
<Stack gap="sm">  <CheckboxCard title="Analytics" description="Page views, referrers and conversion funnels." />  <CheckboxCard title="Alerts" description="Email or Slack when a threshold is crossed." />  <CheckboxCard title="Exports" description="Scheduled CSV delivery to your warehouse." /></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.

Density
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;
<Stack gap="sm">  <CheckboxCard checked={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" />  <CheckboxCard size="sm" checked={on.includes("analytics")} onCheckedChange={...} title="Analytics" />  <CheckboxCard size="sm" checked={on.includes("alerts")} onCheckedChange={...} title="Alerts" />  <CheckboxCard size="sm" checked={on.includes("exports")} onCheckedChange={...} title="Exports" /></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.

Density

Enabled: analytics

import { useState } from "react";import { CheckboxCard } from "@/components/ui/checkbox-card";
const [on, setOn] = useState(["analytics"]);
<CheckboxCard checked={on.includes("analytics")} onCheckedChange={() => toggle("analytics")} title="Analytics" description="Page views, referrers and conversion funnels." />

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.

Density
import { CheckboxCard } from "@/components/ui/checkbox-card";import { Stack } from "@/components/ui/stack";
<Stack gap="sm">  <CheckboxCard title="Analytics" />  <CheckboxCard showDescription={false} title="Alerts" description="Hidden by showDescription={false}." /></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.

Density
import { CheckboxCard } from "@/components/ui/checkbox-card";import { Stack } from "@/components/ui/stack";
<div data-density="comfortable">  <Stack gap="sm">    <CheckboxCard size="xs" title="XS" description="Supporting text." />    <CheckboxCard size="sm" title="SM" description="Supporting text." />    <CheckboxCard size="md" title="MD" description="Supporting text." />    <CheckboxCard size="lg" title="LG" description="Supporting text." />    <CheckboxCard size="xl" title="XL" description="Supporting text." />  </Stack></div>

Disabled

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.

Density
import { CheckboxCard } from "@/components/ui/checkbox-card";import { Stack } from "@/components/ui/stack";
<Stack gap="sm">  <CheckboxCard defaultChecked disabled title="Analytics" description="Included in every plan." />  <CheckboxCard disabled title="Exports" description="Available on the Team plan." /></Stack>