Skip to content
Primitiv home
Framework
Consumption mode

Radio Card

stableSource Figma

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

Density

Preview

Size
import { RadioCard, RadioCardItem } from "@/components/ui/radio-card";import { Stack } from "@/components/ui/stack";
<RadioCard defaultValue="pro" aria-label="Plan">  <Stack gap="sm">    <RadioCardItem value="starter" size="md" showDescription={true} title="Starter" description="One project, community support." />    <RadioCardItem value="pro" size="md" showDescription={true} title="Pro" description="Unlimited projects, email support." />    <RadioCardItem value="team" size="md" showDescription={true} title="Team" description="Shared workspaces and SSO." />  </Stack></RadioCard>

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

Installation

npx primitiv add radio-card

Import

import { RadioCard } from "@/components/ui/radio-card";

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

<RadioCard defaultValue="pro" aria-label="Plan">  <RadioCardItem value="starter" title="Starter" description="One project, community support." />  <RadioCardItem value="pro" title="Pro" description="Unlimited projects, email support." /></RadioCard>

Props

RadioCard.Root

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

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen 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.
childrenReactNodeheadlessThe group's cards — typically RadioCardItemProps`RadioCard.Item` elements, each optionally wrapping a RadioCardIndicatorProps`RadioCard.Indicator`.
defaultValuestringheadlessValue of the card selected on first render. Omit for nothing selected on mount. Forbidden in controlled mode — use value instead.
dir"ltr" | "rtl"headlessReading 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) => voidheadlessCalled 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"headlessWhich 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.
refRef<HTMLDivElement>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 HTMLDivElement.
valuestringheadlessForbidden 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.

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.
value (required)stringheadlessUniquely identifies this card within the group and is reported to onValueChange / matched against Root's value when selected. Required.
descriptionReactNodeheadlessThe card's supporting text, shown below the title.
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 underlying HTMLButtonElement.
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.

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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, renders the single child element via the Slot pattern instead of the default <span>; aria-hidden and data-state are merged onto it.
childrenReactNodeheadlessThe visual mark shown while the parent Item is selected.
forceMountbooleanfalseheadlessWhen 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

Control frame

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

Panel

--primitiv-radio-card-content-gap

Keyboard

KeyBehaviour
TabMove 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 / ArrowRightMove 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 / ArrowLeftMove to the previous Item and select it, wrapping at the start.
SpaceSelect the focused Item. Like a native radio, selection never moves off an Item — pressing again keeps it selected.
EnterAlso 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

RadioCardItem

className: .primitiv-radio-card

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

AttributeValueWhen
data-statechecked | uncheckedthe 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

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.

Density
import { RadioCard, RadioCardItem } from "@/components/ui/radio-card";import { Stack } from "@/components/ui/stack";
// A radiogroup of buttons — no name, nothing in FormData.<RadioCard defaultValue="pro" aria-label="Plan">  <Stack gap="sm">    <RadioCardItem value="starter" title="Starter" description="One project, community support." />    <RadioCardItem value="pro" title="Pro" description="Unlimited projects, email support." />    <RadioCardItem value="team" title="Team" description="Shared workspaces and SSO." />  </Stack></RadioCard>

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.

Density
import { RadioCard, RadioCardItem } from "@/components/ui/radio-card";import { Stack } from "@/components/ui/stack";
<RadioCard defaultValue="pro" orientation="horizontal" aria-label="Plan">  <Stack direction="row" gap="sm">    <RadioCardItem value="starter" title="Starter" description="One project, community support." />    <RadioCardItem value="pro" title="Pro" description="Unlimited projects, email support." />    <RadioCardItem value="team" title="Team" description="Shared workspaces and SSO." />  </Stack></RadioCard>

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.

Density
Plan
You can change this at any time.
import { Field, FieldLabel, FieldDescription } from "@/components/ui/field";import { RadioCard, RadioCardItem } from "@/components/ui/radio-card";import { Stack } from "@/components/ui/stack";
<Field asChild>  <fieldset>    <FieldLabel asChild><legend>Plan</legend></FieldLabel>    <RadioCard defaultValue="pro" aria-labelledby="plan-legend">      <Stack gap="sm">        <RadioCardItem value="starter" title="Starter" description="One project, community support." />        <RadioCardItem value="pro" title="Pro" description="Unlimited projects, email support." />        <RadioCardItem value="team" title="Team" description="Shared workspaces and SSO." />      </Stack>    </RadioCard>    <FieldDescription>You can change this at any time.</FieldDescription>  </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.

Density

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");
<RadioCard value={plan} onValueChange={setPlan} aria-label="Plan">  <Stack gap="sm">    <RadioCardItem value="starter" title="Starter" description="One project, community support." />    <RadioCardItem value="pro" title="Pro" description="Unlimited projects, email support." />    <RadioCardItem value="team" title="Team" description="Shared workspaces and SSO." />  </Stack></RadioCard>

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.

Density
import { RadioCard, RadioCardItem } from "@/components/ui/radio-card";import { Stack } from "@/components/ui/stack";
<div data-density="comfortable">  <RadioCard defaultValue="md" aria-label="Size">    <Stack gap="sm">      <RadioCardItem value="xs" size="xs" title="XS" description="Supporting text." />      <RadioCardItem value="sm" size="sm" title="SM" description="Supporting text." />      <RadioCardItem value="md" size="md" title="MD" description="Supporting text." />      <RadioCardItem value="lg" size="lg" title="LG" description="Supporting text." />      <RadioCardItem value="xl" size="xl" title="XL" description="Supporting text." />    </Stack>  </RadioCard></div>

Disabled

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.

Density
import { RadioCard, RadioCardItem } from "@/components/ui/radio-card";import { Stack } from "@/components/ui/stack";
<RadioCard defaultValue="pro" aria-label="Plan">  <Stack gap="sm">    <RadioCardItem value="starter" disabled title="Starter" description="One project, community support." />    <RadioCardItem value="pro" title="Pro" description="Unlimited projects, email support." />    <RadioCardItem value="team" disabled title="Team" description="Contact sales." />  </Stack></RadioCard>