Photon Design System

Radio Group

Chooses exactly one option from a small, visible set. Built on Base UI RadioGroup + Radio, with arrow-key navigation between options. When the set is long or space is tight, prefer Select.

Import

import {
  RadioGroup,
  Radio,
  RadioCard,
} from "@photon-ai/pho-ui/components/radio-group";

Basic

Each Radio needs a value; label makes the whole row selectable. Leave the group uncontrolled with defaultValue.

<RadioGroup defaultValue="standard">
  <Radio value="standard" label="Standard — 3–5 business days" />
  <Radio value="express" label="Express — next business day" />
  <Radio value="overnight" label="Overnight" />
</RadioGroup>

Disabled

Set disabled on a single Radio to remove just that option, or on RadioGroup to disable every option at once. Disabled options are skipped by arrow-key navigation.

<Radio value="overnight" label="Overnight — not available" disabled />

Controlled

Drive the selection yourself with value and onValueChange when the choice feeds other state.

Selected: standard

const [value, setValue] = useState("standard");

<RadioGroup value={value} onValueChange={setValue}>
  <Radio value="standard" label="Standard" />
  <Radio value="express" label="Express" />
  <Radio value="overnight" label="Overnight" />
</RadioGroup>;

Card

RadioCard wraps the same Radio in a bordered, selectable tile — pass title and an optional description instead of label. Use it when each option carries enough weight (a plan, a brand type) to deserve more visual presence than a plain labeled dot. The whole tile is the hit target and carries the press feedback; arrange a set of them with RadioGroup as usual.

OrganizationA registered company or non-profit.Sole proprietorAn individual with lighter carrier vetting.
<RadioGroup defaultValue="org" className="flex flex-col gap-2 sm:flex-row">
  <RadioCard
    value="org"
    title="Organization"
    description="A registered company or non-profit."
  />
  <RadioCard
    value="sole"
    title="Sole proprietor"
    description="An individual with lighter carrier vetting."
  />
</RadioGroup>

Card with icon

Pass icon when the option has a glyph — it stacks above the title and replaces the radio dot.

OrganizationA registered company or non-profit.Sole proprietorAn individual with lighter carrier vetting.
<RadioGroup defaultValue="org" className="flex flex-col gap-2 sm:flex-row">
  <RadioCard
    value="org"
    title="Organization"
    description="A registered company or non-profit."
    icon={<IconBuilding />}
  />
  <RadioCard
    value="sole"
    title="Sole proprietor"
    description="An individual with lighter carrier vetting."
    icon={<IconUser />}
  />
</RadioGroup>

Card with a trailing glyph

A choice that adds something rather than picks it (a new brand beside the ones already registered) takes trailing: a glyph at the end of the title row, in the dot’s place. The tile’s ring still shows it chosen.

Acme Inc.Verified.New brandAdd one carriers haven't seen yet.
<RadioGroup defaultValue="acme" className="flex flex-col gap-2">
  <RadioCard value="acme" title="Acme Inc." description="Verified." />
  <RadioCard
    value="new"
    title="New brand"
    description="Add one carriers haven't seen yet."
    trailing={<IconPlus />}
  />
</RadioGroup>

Anatomy

  • RadioGroup — owns the selection and enforces single-choice; holds the Radio (or RadioCard) items and is the group’s single tab stop
  • Radio — one option: a dot plus an optional label, where the whole row selects the value
  • RadioCard — one option styled as a bordered tile: title, optional description, and either a stacked icon or a radio dot, where the whole tile selects the value

Props

RadioGroup

  • value / defaultValue — controlled / uncontrolled selection
  • onValueChange — (value, eventDetails) => void
  • name — form field name submitted with the value
  • disabled — disable the whole group (default false)
  • readOnly — block changing the selection (default false)
  • required — require a choice before form submit (default false)

Radio

  • value (required) — this option’s value
  • label — clickable text beside the dot (or pass aria-label)
  • disabled — disable just this option

RadioCard

  • value (required) — this option’s value
  • title (required) — tile heading
  • description — supporting copy shown below the title
  • icon — ReactNode — glyph stacked above the title; when set, it replaces the radio dot
  • trailing — ReactNode — glyph at the end of the title row in place of the radio dot, for a choice that adds something (a plus); for tiles without an icon
  • disabled — disable just this option

Accessibility

RadioGroup renders role="radiogroup" and each Radio renders role="radio" with aria-checked. Passing label wraps the dot and text in a <label>, so the whole row is both clickable and the option’s accessible name; without a label, give the Radio an aria-label. required and readOnly surface as aria-required and aria-readonly on the group. RadioCard renders its title and description inside the radio tile, so they compose into the option’s accessible name.

  • Tab — move focus into the group, landing on the selected option (or the first)
  • Arrow keys — move to the next/previous enabled option and select it
  • Space — select the focused option

The group is a single tab stop: roving tabindex keeps only one radio tabbable, so Tab moves past the group rather than through each option.

Best practices

  • Use a radio group when exactly one choice is required and seeing all options at once helps the decision; collapse to a Select past ~6 options.
  • Always preselect a sensible default so users aren’t forced to make a choice they may not understand yet.
  • Keep labels parallel and scannable — same grammatical shape, shortest text that disambiguates.
  • Every option needs a label or an aria-label; a bare dot has no accessible name.