A card/tile-shaped radio item — the whole bordered surface is the interactive element, not a small control plus a separate label. Composes the headless RadioCard primitive (RadioCard.Root is role="radiogroup" with no visual anatomy of its own; RadioCard.Item is role="radio"). Sized xs–xl; data-density scales each size further.
Playground
Preview
import{RadioCard,RadioCardItem}from"@/components/ui/radio-card";import{Stack}from"@/components/ui/stack";<RadioCarddefaultValue="pro"aria-label="Plan"><Stackgap="sm"><RadioCardItemvalue="starter"size="md"showDescription={true}title="Starter"description="One project, community support."/><RadioCardItemvalue="pro"size="md"showDescription={true}title="Pro"description="Unlimited projects, email support."/><RadioCardItemvalue="team"size="md"showDescription={true}title="Team"description="Shared workspaces and SSO."/></Stack></RadioCard>
import{RadioCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<RadioCard.RootdefaultValue="pro"aria-label="Plan"><Stackgap="sm"><RadioCard.Itemvalue="starter"><RadioCard.IndicatorforceMount/><span>Starter</span><span>One project, community support.</span></RadioCard.Item><RadioCard.Itemvalue="pro"><RadioCard.IndicatorforceMount/><span>Pro</span><span>Unlimited projects, email support.</span></RadioCard.Item><RadioCard.Itemvalue="team"><RadioCard.IndicatorforceMount/><span>Team</span><span>Shared workspaces and SSO.</span></RadioCard.Item></Stack></RadioCard.Root>
Density is set by a data-density ancestor — the Context system, not a RadioCard prop.
Copied into your project as .primitiv-radio-card — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
Note which half is which. RadioCard — the group — is a plain <div role="radiogroup"> with no styling at all, identical in both modes; that is why it has no class, no modifiers and no data attributes, and why the props table's styled column is empty for it. Everything visual is RadioCard.Item, and there the two surfaces diverge like CheckboxCard's do: the copied RadioCardItem takes title and description as props and renders the indicator itself, while the headless Item takes children and expects you to place RadioCard.Indicator. The group also does not lay its Items out — compose them with Stack or a grid.
<RadioCarddefaultValue="pro"aria-label="Plan"><RadioCardItemvalue="starter"title="Starter"description="One project, community support."/><RadioCardItemvalue="pro"title="Pro"description="Unlimited projects, email support."/></RadioCard>
<RadioCard.RootdefaultValue="pro"aria-label="Plan"><RadioCard.Itemvalue="starter"><RadioCard.IndicatorforceMount/><span>Starter</span><span>One project, community support.</span></RadioCard.Item><RadioCard.Itemvalue="pro"><RadioCard.IndicatorforceMount/><span>Pro</span><span>Unlimited projects, email support.</span></RadioCard.Item></RadioCard.Root>
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
RadioCard.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When true, Root delegates rendering to a single consumer-supplied
element via the Slot pattern instead of the default <div>;
role="radiogroup", aria-orientation, dir, and remaining props
are merged onto it.
children
ReactNode
—
headless
The group's cards — typically RadioCardItemProps`RadioCard.Item`
elements, each optionally wrapping a RadioCardIndicatorProps`RadioCard.Indicator`.
defaultValue
string
—
headless
Value of the card selected on first render. Omit for nothing selected
on mount.
Forbidden in controlled mode — use value instead.
dir
"ltr" | "rtl"
—
headless
Reading direction. In "rtl" the horizontal arrow pair is swapped so
Arrow Left moves forward. When omitted, it is inherited from the
nearest DirectionProvider, falling back to "ltr".
onValueChange
(value: string) => void
—
headless
Called with the new value whenever the selection changes. Optional in
uncontrolled mode.
Called with the new value whenever the user selects a card. Required
in controlled mode.
orientation
"horizontal" | "vertical" | "both"
"both"
headless
Which arrow keys move focus and selection through the group.
"both" enables all four arrows; "horizontal" only Arrow
Left/Right; "vertical" only Arrow Up/Down. When not "both", the
value is also reflected as aria-orientation on the root.
ref
Ref<HTMLDivElement>
—
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 HTMLDivElement.
value
string
—
headless
Forbidden in uncontrolled mode — use defaultValue instead.
The currently selected value. Must be kept in sync by the parent via
onValueChange.
RadioCard.Item
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
The card's heading. Always present — the required content.
value* (required)
string
—
headless
Uniquely identifies this card within the group and is reported to
onValueChange / matched against Root's value when selected. Required.
description
ReactNode
—
headless
The card's supporting text, shown below the title.
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 underlying HTMLButtonElement.
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.
RadioCard.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, renders the single child element via the Slot
pattern instead of the default <span>; aria-hidden and data-state
are merged onto it.
children
ReactNode
—
headless
The visual mark shown while the parent Item is selected.
forceMount
boolean
false
headless
When true, keeps the indicator mounted while unchecked so a CSS exit
animation can play against data-state="unchecked". Consumers who set
it own the exit lifecycle.
Styling contract
28 CSS custom properties on .primitiv-radio-card — mode-agnostic. These names are the stable surface; the values are not.
The full ARIA radio-group model: the group is one tab stop, and the arrow keys move focus and selection together. Which arrows work depends on the group's orientation — "both" (the default) enables all four.
Key
Behaviour
Tab
Move focus into the group, landing on the selected Item — or, with nothing selected, the first non-disabled one. The whole group is a single tab stop, not one per option (a roving tabindex).
ArrowDown / ArrowRight
Move to the next Item and select it, wrapping at the end and skipping disabled Items. ArrowRight is inert when orientation="vertical", and follows dir in RTL.
ArrowUp / ArrowLeft
Move to the previous Item and select it, wrapping at the start.
Space
Select the focused Item. Like a native radio, selection never moves off an Item — pressing again keeps it selected.
Enter
Also selects, because each Item is a real <button>. A native radio would submit the form instead; there is no form here to submit.
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.
RadioCardItem
className:.primitiv-radio-card
Attribute
Value
When
data-state
checked | unchecked
checked / unchecked
data-disabled
""
disabled
RadioCard.Item
Attribute
Value
When
data-state
checked | unchecked
checked / unchecked
data-disabled
""
disabled
RadioCard.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-radio-card__dot
Attribute
Value
When
data-state
checked | unchecked
the item's selected state
RadioCard.Indicator
Attribute
Value
When
data-state
checked | unchecked
the item's selected state
Accessibility
Radio or RadioCard?Radio is a real <input type="radio"> in a <label>: siblings sharing a name are grouped by the browser, and the chosen value submits with the form. RadioCard is a radiogroup of buttons — no inputs, no name, nothing in FormData — for when each option is a substantial choice with supporting text that deserves a whole surface. Choose on form participation first, not on looks.
Always name the group. A radiogroup with no accessible name is the most common mistake here: the options are announced individually with no indication of what is being chosen. Pass aria-label, or aria-labelledby pointing at visible heading text — or wrap the group in a <fieldset> with a <legend>, as the labelling example does.
Each Item's accessible name comes from its content, so it includes the description as well as the title. That is usually right for a card whose description is part of the choice; pass an explicit aria-label on the Item when the description is long or is boilerplate, so the announcement stays the option's name.
Set orientation to match the layout you compose. It does two things at once — it decides which arrow keys navigate, and it publishes aria-orientation — so a row of Items left at the default "both" works by keyboard but tells assistive technology nothing about the axis. "both" omits the attribute deliberately, since claiming an axis that does not exist would be worse.
The Root's dir feeds the keymap: in RTL, ArrowRight moves to the previous Item. Set it on the group (or inherit it from a dir ancestor) rather than hand-swapping the keys.
aria-checked carries the selected state and data-state (checked / unchecked) is its CSS mirror; style off data-state. Unlike Radio, data-state here can never lag — React owns the selection outright, so there is no browser-side deselection to miss.
The indicator is aria-hidden and holds no state; it is decoration over aria-checked. forceMount (which the copied Item always passes) only keeps it in the DOM so a CSS exit animation can play.
A group should have a default. defaultValue makes the tab stop the selected Item and saves every user from an empty required choice; without one, focus lands on the first Item without selecting it, which is correct but leaves the group unanswered.
The whole card is the hit target, so anything interactive inside an Item would be a nested control inside a button, which is invalid. Keep links and buttons out of the cards; put them beside the group.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
The group owns the value (the headline)
These are <button role="radio">s in a <div role="radiogroup">, and the group holds the selected value in React. Two consequences. Nothing submits with a form — there is no input and no name, so read value and post it yourself, or use Radio when you want the browser's own form handling. And the grouping is explicit: Radio becomes a group because siblings share a name, whereas here the group is a real component that owns the state, which is why arrow keys and the single tab stop work no matter how you lay the Items out.
import{RadioCard,RadioCardItem}from"@/components/ui/radio-card";import{Stack}from"@/components/ui/stack";// A radiogroup of buttons — no name, nothing in FormData.<RadioCarddefaultValue="pro"aria-label="Plan"><Stackgap="sm"><RadioCardItemvalue="starter"title="Starter"description="One project, community support."/><RadioCardItemvalue="pro"title="Pro"description="Unlimited projects, email support."/><RadioCardItemvalue="team"title="Team"description="Shared workspaces and SSO."/></Stack></RadioCard>
import{RadioCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";// A radiogroup of buttons — no name, nothing in FormData.<RadioCard.RootdefaultValue="pro"aria-label="Plan"><Stackgap="sm"><RadioCard.Itemvalue="starter"><RadioCard.IndicatorforceMount/><span>Starter</span><span>One project, community support.</span></RadioCard.Item><RadioCard.Itemvalue="pro"><RadioCard.IndicatorforceMount/><span>Pro</span><span>Unlimited projects, email support.</span></RadioCard.Item><RadioCard.Itemvalue="team"><RadioCard.IndicatorforceMount/><span>Team</span><span>Shared workspaces and SSO.</span></RadioCard.Item></Stack></RadioCard.Root>
Layout and orientation
The group renders no layout of its own, so a row is just a row-direction Stack inside it. What you must keep in step is orientation: it decides which arrow keys navigate, and it defaults to "both" (all four). Set "horizontal" on a row or "vertical" on a column and the group also publishes aria-orientation, so a screen reader announces the axis that is actually on screen. Leaving it at "both" is not wrong — it just declines to tell anyone which way the options run.
import{RadioCard,RadioCardItem}from"@/components/ui/radio-card";import{Stack}from"@/components/ui/stack";<RadioCarddefaultValue="pro"orientation="horizontal"aria-label="Plan"><Stackdirection="row"gap="sm"><RadioCardItemvalue="starter"title="Starter"description="One project, community support."/><RadioCardItemvalue="pro"title="Pro"description="Unlimited projects, email support."/><RadioCardItemvalue="team"title="Team"description="Shared workspaces and SSO."/></Stack></RadioCard>
import{RadioCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<RadioCard.RootdefaultValue="pro"orientation="horizontal"aria-label="Plan"><Stackdirection="row"gap="sm"><RadioCard.Itemvalue="starter"><RadioCard.IndicatorforceMount/><span>Starter</span><span>One project, community support.</span></RadioCard.Item><RadioCard.Itemvalue="pro"><RadioCard.IndicatorforceMount/><span>Pro</span><span>Unlimited projects, email support.</span></RadioCard.Item><RadioCard.Itemvalue="team"><RadioCard.IndicatorforceMount/><span>Team</span><span>Shared workspaces and SSO.</span></RadioCard.Item></Stack></RadioCard.Root>
Labelling the group
A radiogroup with no accessible name announces three options and no idea what they are choosing between, so the group needs aria-label or aria-labelledby — every example on this page passes one. When the choice needs visible label text and a hint, Field with asChild gets you a real <fieldset> and <legend> while keeping its layout and description. Note what this deliberately avoids: a plain Field.Label renders a <label htmlFor> pointing at an id no Item claims — RadioCard does not read FieldContext — whereas a <legend> names its fieldset directly and needs no id at all.
import{Field,FieldLabel,FieldDescription}from"@/components/ui/field";import{RadioCard,RadioCardItem}from"@/components/ui/radio-card";import{Stack}from"@/components/ui/stack";<FieldasChild><fieldset><FieldLabelasChild><legend>Plan</legend></FieldLabel><RadioCarddefaultValue="pro"aria-labelledby="plan-legend"><Stackgap="sm"><RadioCardItemvalue="starter"title="Starter"description="One project, community support."/><RadioCardItemvalue="pro"title="Pro"description="Unlimited projects, email support."/><RadioCardItemvalue="team"title="Team"description="Shared workspaces and SSO."/></Stack></RadioCard><FieldDescription>You can change this at any time.</FieldDescription></fieldset></Field>
import{Field}from"@primitiv-ui/react";import{RadioCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<FieldasChild><fieldset><Field.LabelasChild><legend>Plan</legend></Field.Label><RadioCard.RootdefaultValue="pro"aria-labelledby="plan-legend"><Stackgap="sm"><RadioCard.Itemvalue="starter"><RadioCard.IndicatorforceMount/><span>Starter</span><span>One project, community support.</span></RadioCard.Item><RadioCard.Itemvalue="pro"><RadioCard.IndicatorforceMount/><span>Pro</span><span>Unlimited projects, email support.</span></RadioCard.Item><RadioCard.Itemvalue="team"><RadioCard.IndicatorforceMount/><span>Team</span><span>Shared workspaces and SSO.</span></RadioCard.Item></Stack></RadioCard.Root><Field.Description>You can change this at any time.</Field.Description></fieldset></Field>
Controlled
Pass value and onValueChange on the group — not on the Items — and you own the selection. This is the normal way to use the component, because with no form to submit into the value has to reach your code somehow. Uncontrolled works too via defaultValue; the props table lists both, but TypeScript accepts one or the other.
Selected: pro
import{ useState }from"react";import{RadioCard,RadioCardItem}from"@/components/ui/radio-card";import{Stack}from"@/components/ui/stack";const[plan, setPlan]=useState("pro");<RadioCardvalue={plan}onValueChange={setPlan}aria-label="Plan"><Stackgap="sm"><RadioCardItemvalue="starter"title="Starter"description="One project, community support."/><RadioCardItemvalue="pro"title="Pro"description="Unlimited projects, email support."/><RadioCardItemvalue="team"title="Team"description="Shared workspaces and SSO."/></Stack></RadioCard>
import{ useState }from"react";import{RadioCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";const[plan, setPlan]=useState("pro");<RadioCard.Rootvalue={plan}onValueChange={setPlan}aria-label="Plan"><Stackgap="sm"><RadioCard.Itemvalue="starter"><RadioCard.IndicatorforceMount/><span>Starter</span><span>One project, community support.</span></RadioCard.Item><RadioCard.Itemvalue="pro"><RadioCard.IndicatorforceMount/><span>Pro</span><span>Unlimited projects, email support.</span></RadioCard.Item><RadioCard.Itemvalue="team"><RadioCard.IndicatorforceMount/><span>Team</span><span>Shared workspaces and SSO.</span></RadioCard.Item></Stack></RadioCard.Root>
Sizes and density
size is the Item's prop, not the group's — the group has no styling to size. Five sizes, each rescaling again with the nearest data-density ancestor, and matching CheckboxCard's scale exactly so a form mixing single-choice and multi-choice cards keeps one baseline.
disabled on an Item forwards the native <button> attribute, and the group excludes it from two things: arrow-key navigation skips over it, and it can never be the group's tab stop. That second part matters — with nothing selected the tab stop is the first non-disabled Item, so a group whose first option is unavailable still receives focus somewhere sensible.
import{RadioCard,RadioCardItem}from"@/components/ui/radio-card";import{Stack}from"@/components/ui/stack";<RadioCarddefaultValue="pro"aria-label="Plan"><Stackgap="sm"><RadioCardItemvalue="starter"disabledtitle="Starter"description="One project, community support."/><RadioCardItemvalue="pro"title="Pro"description="Unlimited projects, email support."/><RadioCardItemvalue="team"disabledtitle="Team"description="Contact sales."/></Stack></RadioCard>
import{RadioCard}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<RadioCard.RootdefaultValue="pro"aria-label="Plan"><Stackgap="sm"><RadioCard.Itemvalue="starter"disabled><RadioCard.IndicatorforceMount/><span>Starter</span><span>One project, community support.</span></RadioCard.Item><RadioCard.Itemvalue="pro"><RadioCard.IndicatorforceMount/><span>Pro</span><span>Unlimited projects, email support.</span></RadioCard.Item><RadioCard.Itemvalue="team"disabled><RadioCard.IndicatorforceMount/><span>Team</span><span>Contact sales.</span></RadioCard.Item></Stack></RadioCard.Root>