Skip to content
Primitiv home
Framework
Consumption mode

Radio

stableSource Figma

A single native radio input with an optional inline label, grouped with a shared name.

Playground

Density

Preview

Size
import { Radio } from "@/components/ui/radio";
<Radio size="md" name="plan" value="pro">Pro</Radio>

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

Installation

npx 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

<Radio name="plan" value="pro">Pro</Radio>

Props

Radio.Root

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

PropTypeDefaultFromDescription
checkedbooleanheadlessForbidden 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.
childrenReactNodeheadless
defaultCheckedbooleanheadlessWhether 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) => voidheadlessFired 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.
refRef<HTMLInputElement>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 native <input type="radio">.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen 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.
childrenReactNodeheadlessCustom 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

--primitiv-radio-bg--primitiv-radio-border-color--primitiv-radio-border-color-checked--primitiv-radio-border-width--primitiv-radio-dot-color--primitiv-radio-size--primitiv-radio-radius--primitiv-radio-dot-size--primitiv-radio-gap--primitiv-radio-label-color--primitiv-radio-label-font-family--primitiv-radio-label-font-size--primitiv-radio-label-font-weight--primitiv-radio-label-line-height

Keyboard

KeyBehaviour
TabMove 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 / ArrowRightMove to the next radio in the group and select it. Wraps at the end, and skips disabled options.
ArrowUp / ArrowLeftMove to the previous radio and select it.
SpaceSelect 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

Radio

className: .primitiv-radio

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

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.

Density
import { Radio } from "@/components/ui/radio";import { Stack } from "@/components/ui/stack";
<Stack gap="sm">  <Radio name="plan" value="free">Free</Radio>  <Radio name="plan" value="pro">Pro</Radio>  <Radio name="plan" value="team">Team</Radio></Stack>

Labelling the group

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.

Density
Plan
You can change this at any time.
import { Field, FieldLabel, FieldDescription } from "@/components/ui/field";import { Radio } from "@/components/ui/radio";import { Stack } from "@/components/ui/stack";
<Field asChild>  <fieldset>    <FieldLabel asChild><legend>Plan</legend></FieldLabel>    <Stack gap="sm">      <Radio name="tier" value="free">Free</Radio>      <Radio name="tier" value="pro">Pro</Radio>      <Radio name="tier" value="team">Team</Radio>    </Stack>    <FieldDescription>You can change this at any time.</FieldDescription>  </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.

Density

Selected: pro

import { useState } from "react";import { Radio } from "@/components/ui/radio";
const [plan, setPlan] = useState("pro");
<Radio name="plan" value="free" checked={plan === "free"} onCheckedChange={() => setPlan("free")}>Free</Radio><Radio name="plan" value="pro" checked={plan === "pro"} onCheckedChange={() => setPlan("pro")}>Pro</Radio><Radio name="plan" value="team" checked={plan === "team"} onCheckedChange={() => setPlan("team")}>Team</Radio>

Sizes and density

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.

Density
import { Radio } from "@/components/ui/radio";
<div data-density="comfortable">  <Radio size="xs" name="size-demo" value="xs">Pro</Radio>  <Radio size="sm" name="size-demo" value="sm">Pro</Radio>  <Radio size="md" name="size-demo" value="md">Pro</Radio>  <Radio size="lg" name="size-demo" value="lg">Pro</Radio>  <Radio size="xl" name="size-demo" value="xl">Pro</Radio></div>

Disabled

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.

Density
import { Radio } from "@/components/ui/radio";import { Stack } from "@/components/ui/stack";
<Stack gap="sm">  <Radio name="plan" value="free" defaultChecked>Free</Radio>  <Radio name="plan" value="team" disabled>Team (contact sales)</Radio></Stack>