Density is set by a data-density ancestor — the Context system, not a Radio prop.
Installation
npx primitiv add radio
pnpm dlx primitiv add radio
yarn dlx primitiv add radio
bunx primitiv add radio
Import
import{Radio}from"@/components/ui/radio";
Copied into your project as .primitiv-radio — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
The same shape difference Checkbox and Switch have. @primitiv-ui/react exports Root and Indicator and leaves you to place the dot; the copied file exports a single Radio that renders the box, the dot and the label span itself — so there is no RadioIndicator to import under Styled. Either way the Root renders a real <label> wrapping a visually-hidden <input type="radio">.
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Radio.Root
Extends HTMLInputElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
checked
boolean
—
headless
Forbidden in uncontrolled mode — use defaultChecked instead.
Whether this radio is currently selected, owned by the parent. The
consumer owns grouping in this mode (typically deriving each radio's
checked from a single shared value). Keep it in sync via
onCheckedChange.
children
ReactNode
—
headless
defaultChecked
boolean
—
headless
Whether this radio is selected on first render; the browser owns it
thereafter, so native name-grouping (including silent deselection of
siblings) works for free. Omit for an initially unselected radio.
Forbidden in controlled mode — use checked instead.
onCheckedChange
(checked: boolean) => void
—
headless
Fired with the new checked value whenever this radio becomes selected.
A native radio only ever fires change when it moves into the checked
state, so this is always called with true.
Called (always with true) whenever this radio becomes selected —
required in controlled mode so the parent can update its shared value.
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="radio">.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Control size; data-density scales each size further.
Radio.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 dot content. Omit to let the shipped CSS draw the dot off the
input's native :checked state; provide your own (an icon, glyph, or
nested element) to override it.
Styling contract
14 CSS custom properties on .primitiv-radio — mode-agnostic. These names are the stable surface; the values are not.
All of it native, because these are real radio inputs — the browser treats same-name siblings as one group and gives you the roving behaviour for free.
Key
Behaviour
Tab
Move focus into the group, landing on the selected radio (or the first, if none is selected) — the group is a single tab stop, not one per option.
ArrowDown / ArrowRight
Move to the next radio in the group and select it. Wraps at the end, and skips disabled options.
ArrowUp / ArrowLeft
Move to the previous radio and select it.
Space
Select the focused radio. Selection never moves off a radio by clicking it again — a native radio only ever moves into the checked state.
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.
Radio
className:.primitiv-radio
Attribute
Value
When
data-state
checked | unchecked
checked / unchecked
data-disabled
""
disabled
Radio.Root
Attribute
Value
When
data-state
checked | unchecked
checked / unchecked
data-disabled
""
disabled
Accessibility
Radio or RadioGroup?Radio is the lone native control, for when you own the grouping — a shared name, a bespoke layout, a single opt-in. The headless RadioGroup is the managed alternative: it composes role="radio" items, owns the selected value, and implements roving tabindex itself. It has no copied styled surface, so under the Styled tab Radio is the one to reach for.
Label the group, not just the options. Each radio names itself, but without a group label the choice is announced as three unrelated options — use Field (as the second example does) or a <fieldset> with a <legend>.
The shared name is what makes it a group in the accessibility tree, not just in your layout. Radios that look grouped but have different names are announced separately, and the arrow keys stop working — if the keyboard behaviour is wrong, that is the first thing to check.
A group should have a default. Tabbing into a group with nothing selected lands on the first option without selecting it, which is fine, but shipping a form where every group starts empty forces a choice on every user — pick a sensible defaultChecked where one exists.
The input is visually hidden, not display: none — a hidden-by-display input leaves the accessibility tree and stops submitting. It stays focusable and reachable.
data-state is a best-effort mirror and can lag: when the browser silently deselects a sibling, no React event fires for that sibling. Key the visual selected look off the input's native :checked (the shipped stylesheet uses :has(> input:checked)), which is always right.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
Grouping (the headline)
Give sibling radios the same name and the browser groups them — no shared state, no context, no controlled wiring. Selecting one deselects the rest, arrow keys move between them, and inside a <form> the chosen value submits under that name. This is the whole reason Radio exists as a lone control: the platform already implements the hard parts, so the component does not re-implement them.
Each radio labels itself, but the set needs a name too — otherwise the options are announced with no idea what they are choosing between. The native answer is a <fieldset> whose <legend> names the group, which asChild gets you from Field without losing its layout or description. Note what this example deliberately does not do: a plain Field.Label beside radios renders a <label htmlFor> pointing at an id no radio claims — Radio does not read FieldContext — so the label would associate with nothing. A <legend> names its fieldset directly and needs no id at all.
import{Field,FieldLabel,FieldDescription}from"@/components/ui/field";import{Radio}from"@/components/ui/radio";import{Stack}from"@/components/ui/stack";<FieldasChild><fieldset><FieldLabelasChild><legend>Plan</legend></FieldLabel><Stackgap="sm"><Radioname="tier"value="free">Free</Radio><Radioname="tier"value="pro">Pro</Radio><Radioname="tier"value="team">Team</Radio></Stack><FieldDescription>You can change this at any time.</FieldDescription></fieldset></Field>
import{Field}from"@primitiv-ui/react";import{Radio}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<FieldasChild><fieldset><Field.LabelasChild><legend>Plan</legend></Field.Label><Stackgap="sm"><Radio.Rootname="tier"value="free"><Radio.Indicator/> Free</Radio.Root><Radio.Rootname="tier"value="pro"><Radio.Indicator/> Pro</Radio.Root><Radio.Rootname="tier"value="team"><Radio.Indicator/> Team</Radio.Root></Stack><Field.Description>You can change this at any time.</Field.Description></fieldset></Field>
Controlled
Pass checked and onCheckedChange together and you own the value — worth it when the selection drives something else on the page. Note the shape: onCheckedChange fires on the radio being selected, so you set the group's value from that radio's own value rather than reading a shared event. As with the other choice controls, the props table flattens the controlled and uncontrolled shapes but TypeScript accepts only one at a time.
Five sizes, each rescaling again with the nearest data-density ancestor. The box is deliberately the same height as Checkbox's box and Switch's track at every size and density, so a form mixing the three choice controls keeps one baseline.
disabled forwards the native attribute, so the option cannot be chosen and leaves the tab order; data-disabled lands on the box for styling. Disabling one option of a group is the common case — the rest stay selectable, and the browser skips the disabled one with the arrow keys.
import{Radio}from"@primitiv-ui/react";import{Stack}from"@/components/ui/stack";<Stackgap="sm"><Radio.Rootname="plan"value="free"defaultChecked><Radio.Indicator/> Free</Radio.Root><Radio.Rootname="plan"value="team"disabled><Radio.Indicator/> Team (contact sales)</Radio.Root></Stack>