RadioGroup

Context-based radio group; the group owns name, checked state, and change handling.

atomsSource
import { RadioGroup, RadioGroupItem } from '@elirobinson/react/components/atoms/RadioGroup';

Styles: @elirobinson/react/styles/molecules/RuleLink.css — already included when you import @elirobinson/react/styles.css.

Show code
import { RadioGroup, RadioGroupItem } from '@elirobinson/react/components/atoms/RadioGroup';

export default function Basic() {
  return (
    <RadioGroup name="plan" defaultValue="pro">
      <RadioGroupItem value="free" label="Free" />
      <RadioGroupItem value="pro" label="Pro" />
      <RadioGroupItem value="enterprise" label="Enterprise" />
    </RadioGroup>
  );
}

When to use it

Use RadioGroup for a small, always-visible set of mutually exclusive options — two to six is the sweet spot. Once the list gets long enough to need scrolling or search, reach for Select or Combobox instead.

RadioGroup is context-based: the group owns name, the current value, and change handling. RadioGroupItem only takes value and label — it can't take checked, onChange, or name directly, so there's no way to desync an item from its group. Rendering a RadioGroupItem outside a RadioGroup throws immediately; the two aren't meant to be used apart.

Controlled

Pass value and onValueChange to drive selection from your own state. Passing defaultValue instead makes it uncontrolled — don't pass both.

Booking a 60-minute session.

Show code
import { useState } from 'react';

import { RadioGroup, RadioGroupItem } from '@elirobinson/react/components/atoms/RadioGroup';

export default function Controlled() {
  const [duration, setDuration] = useState('60');

  return (
    <div className="demo-col">
      <RadioGroup name="session-length" value={duration} onValueChange={setDuration}>
        <RadioGroupItem value="30" label="30 minutes" />
        <RadioGroupItem value="60" label="60 minutes" />
        <RadioGroupItem value="90" label="90 minutes" />
      </RadioGroup>
      <p>Booking a {duration}-minute session.</p>
    </div>
  );
}

Clearing a controlled group

value distinguishes empty from absent, and the difference decides the mode:

  • null — controlled, nothing selected. This is how you clear a controlled group.
  • undefined — uncontrolled. The group falls back to defaultValue and its own internal state, exactly as if value had never been passed.

So type your state string | null, not string | undefined:

const [plan, setPlan] = useState<string | null>(null);

<RadioGroup name="plan" value={plan} onValueChange={setPlan}>
  <RadioGroupItem value="free" label="Free" />
  <RadioGroupItem value="pro" label="Pro" />
</RadioGroup>
<button onClick={() => setPlan(null)}>Clear</button>;

Clearing with undefined instead does not clear the group — it hands selection back to the group's own state, which still holds the last click. TypeScript can't catch that, since undefined is legal for an optional prop, so the group warns in development when value goes from a string to undefined.

Form submission

The group works with native form submission on its own — no hidden input, no extra wiring. RadioGroupItem renders a real input[type="radio"], so the group's name is also the submitted field name, and the selection is in FormData and in a server action's payload under that name:

<form action={savePlan}>
  <RadioGroup name="plan" defaultValue="free">
    <RadioGroupItem value="free" label="Free" />
    <RadioGroupItem value="pro" label="Pro" />
  </RadioGroup>
  <Button type="submit">Save</Button>
</form>

formData.get('plan') is 'free' or 'pro'. This holds in both modes — a controlled group submits its current selection too.

Props

PropTypeDefaultDescription
namerequiredstringShared `name` for every item's `input[type="radio"]`, which makes it the field name the group submits under. The group participates in native form submission with no extra wiring: inside a `<form>` its selection shows up in `FormData` and in a server action's payload under this `name`. Nothing hidden needs to be added to carry the value across.
defaultValuestring | number | readonly string[]
onValueChange((value: string) => void)
valuestring | nullSelection, when the group is controlled. `null` means controlled with nothing selected; `undefined` means uncontrolled, and hands the selection back to `defaultValue` and the group's own state. The distinction matters: clear a controlled group with `null`, never with `undefined`.

Also accepts all Omit<HTMLAttributes<HTMLDivElement>, 'role'> props.

RadioGroupItem

PropTypeDefaultDescription
labelrequiredstring
defaultValuestring | number | readonly string[]
valuestring | number | readonly string[]

Also accepts all Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'name' | 'checked' | 'onChange'> props.

Extraction notes: ds-radio-group styles are defined in molecules/RuleLink.css, not a RadioGroup sheet.

Accessibility

  • The wrapper renders role="radiogroup"; each RadioGroupItem renders a native input[type="radio"] sharing the group's name.
  • Keyboard: because the inputs share a native name, arrow keys move between items and select as they go, and Tab treats the whole group as a single stop — that's the browser's built-in radio-group behavior, not something this component reimplements.
  • RadioGroup has no label prop of its own — introduce it with a standalone Label connected via aria-labelledby (a plain div isn't a labelable element, so htmlFor doesn't reliably associate here).
  • The ref on RadioGroup forwards to the wrapping <div>; the ref on RadioGroupItem forwards to its <input>.

Do

  • Give RadioGroup a name and either defaultValue (uncontrolled) or value + onValueChange (controlled) — pick one mode.
  • Type controlled state as string | null and clear it with null — undefined means uncontrolled.
  • Rely on the group name for form submission; it submits natively, so no hidden input is needed.
  • Use it for 2–6 mutually exclusive, always-visible options.
  • Pair a standalone Label above the group via aria-labelledby, since RadioGroup has no label prop of its own.
  • Give every RadioGroupItem a distinct, real value and label.

Don't

  • Try to pass checked, onChange, or name to a RadioGroupItem — the type doesn’t allow it, and the group owns all three.
  • Render a RadioGroupItem outside a RadioGroup — it throws immediately, with no fallback behavior.
  • Reach for RadioGroup when the option list is long or needs search — use Select or Combobox.
  • Pass both value and defaultValue — value wins, and defaultValue is only read on the first render.
  • Clear a controlled group with undefined — that switches it to uncontrolled and keeps the last selection. Pass null.
  • Add a hidden input to carry the selection to a server action — the group already submits under its own name.