# Radio Group

> A React radio group to pick one of a few visible options: rows with a dot that springs in, or cards with icons, prices and a sliding ring.

- Type: React component (Inputs)
- Access: Free (the source is open to everyone)
- Version: 1.0.1
- Install: `npx wingo-ui@latest add radio-group`
- Docs: https://wingo-ui.com/components/radio-group

## Interaction

Click or tap anywhere on a row or card to choose it: the circle fills with the tone and the dot springs in, and on cards a ring slides from the old choice to the new one while the icon tile takes the tone. The circle dips under your finger while you press, and a card dips slightly as a whole. Tab lands on the chosen option (or the first one when nothing is chosen), the arrow keys move and choose at once (Up and Left go back, Down and Right go forward, mirrored in right-to-left layouts, wrapping at the ends unless loop is off), Space chooses the focused option, and Tab leaves the group. Clicking the group's label focuses the chosen option. On a phone every row grows to 44px so each one is a full finger target, grids drop columns once a cell would get too narrow (a phone always gets one), cards stack their icon above the text when they are narrow, and horizontal rows wrap instead of scrolling. A read-only group keeps its choice visible and focusable but ignores changes; a disabled option dims and is skipped by the arrow keys. With reduced motion the dot and the ring jump instead of springing.

## Installation

```bash
npx wingo-ui@latest add radio-group
```

The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add https://wingo-ui.com/r/radio-group.json`

Files:

- `components/ui/radio-group.tsx`

npm dependencies: `npm i @radix-ui/react-radio-group motion`

Wingo UI dependencies (installed automatically):

- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { Package, Store, Truck } from "lucide-react"
import { RadioGroup, type RadioOption } from "@/components/ui/radio-group"

const delivery: RadioOption[] = [
  { value: "courier", label: "Home delivery", description: "FedEx, 1-2 days", icon: <Truck />, badge: "$9.99" },
  { value: "locker", label: "Locker", description: "Pick up any time from 3,000 lockers", icon: <Package />, badge: "$5.99" },
  { value: "pickup", label: "In-store pickup", description: "Austin, 2400 S Congress Ave", icon: <Store />, badge: "Free" },
]

export function DeliveryMethod({ value, onChange }: { value: string; onChange: (value: string) => void }) {
  return (
    <RadioGroup
      name="delivery"
      label="Delivery method"
      variant="card"
      options={delivery}
      value={value}
      onValueChange={onChange}
      required
    />
  )
}

// custom content: compose the parts
import { RadioGroupRoot, RadioGroupItem } from "@/components/ui/radio-group"

export function PlanPicker() {
  return (
    <RadioGroupRoot label="Plan" variant="card" columns={2} defaultValue="pro">
      <RadioGroupItem value="free" label="Free" badge="$0" />
      <RadioGroupItem value="pro" label="Pro" badge="$9/mo">
        <ul className="mt-2 text-[13px] text-muted-foreground">
          <li>Unlimited listings</li>
        </ul>
      </RadioGroupItem>
    </RadioGroupRoot>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options?` | `RadioOption[]` | `"[]"` | The choices: { value, label, description?, icon?, badge?, disabled? }. badge is a trailing price or tag on the label's line. For layouts an array cannot express, compose RadioGroupRoot with RadioGroupItem children (see Parts). |
| `value?` | `string` |  | The chosen value when controlled. Pair it with onValueChange. |
| `onValueChange?` | `(value: string) => void` |  | Called with the new value when the person picks an option, by pointer, Space or the arrow keys. Not called while readOnly. |
| `label?` | `ReactNode` | `""` | The group's label, above the options. It names the radiogroup through aria-labelledby, and clicking it focuses the chosen option. Leave it empty and pass aria-label for a bare group. |
| `description?` | `ReactNode` | `""` | Helper text under the options, linked with aria-describedby. The error takes its place while it shows. |
| `error?` | `ReactNode` | `""` | Replaces the description with an alert icon and the text, slides in and marks the group invalid: empty circles and card borders turn red. |
| `required?` | `boolean` | `false` | The red mark after the label, aria-required on the group, and required on the hidden form inputs, so a native form will not submit without a choice. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message, for forms that show errors elsewhere. |
| `disabled?` | `boolean` | `false` | Dims every option and blocks it; a form will not submit its value. Disable one option through its own disabled. |
| `readOnly?` | `boolean` | `false` | The choice stays visible and focusable, but clicks and arrow keys do not change it (aria-readonly). No hover tint, no press dip. |
| `variant?` | `"default" \| "card"` | `"default"` | default: compact rows with the circle beside the text. card: soft-filled tiles with an icon tile, a description and a trailing price, and one selection ring that slides between cards. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Circle 16 / 18 / 20px with a 6 / 7 / 8px dot, label 13 / 14 / 15px, card padding 12 / 16 / 20px. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The checked fill, the dot's contrast color and the card ring. neutral is black in light mode and white in dark mode; brand orange is opt-in. |
| `color?` | `string` | `""` | Any CSS color. Replaces the tone; the dot turns black or white by lightness. Leave empty to use the tone. |
| `indicator?` | `"dot" \| "check"` | `"dot"` | What appears in the filled circle: a dot that springs in, or a check mark that draws itself (nice on cards). |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Cards only: corners of 0 / 12 / 16 / 24px (full falls back to 24px). The ring follows it. |
| `orientation?` | `"vertical" \| "horizontal"` | `"vertical"` | horizontal puts rows side by side and wraps them onto the next line instead of scrolling; cards share the row until they would get narrower than 10rem. Every arrow key works in both, like native radios. |
| `columns?` | `1 \| 2 \| 3 \| 4` | `1` | Up to this many columns of rows or cards. The grid follows the space it has, not the viewport: it drops a column whenever cells would get narrower than 12rem (cards) or 11rem (rows), so a phone always gets one column and a narrow sidebar never squeezes the text. |
| `controlPosition?` | `"auto" \| "start" \| "end"` | `"auto"` | Where the circle sits: auto is before the text for rows and at the top end of cards. end suits settings lists; start suits cards that read like a checklist. |
| `showControl?` | `boolean` | `true` | Cards only: false hides the circle and lets the ring and the tinted icon tile show the choice (plan pickers). The radio stays in the accessibility tree. |
| `defaultValue?` | `string` | `""` | The option chosen on first render while uncontrolled. "" chooses nothing, and the first enabled option is then the tab stop. A value that matches no option also shows nothing chosen. The demo starts on curier while this is empty; try easybox or ridicare. |
| `loop?` | `boolean` | `true` | The arrow keys wrap from the last option to the first and back. The demo's key hint says which. |
| `name?` | `string` | `""` | Form field name. Inside a <form> each option renders a hidden native radio with this name, so FormData and native validation work; a form reset returns to defaultValue. The demo prints what the form would submit. |
| `id?` | `string` |  | The group's id (the radiogroup element). Defaults to useId(); the label and messages derive their ids from it. |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel. |
| `classNames?` | `Partial<Record<"group" \| "option" \| "body" \| "item" \| "indicator" \| "icon" \| "content" \| "label" \| "description" \| "badge" \| "ring" \| "field" \| "fieldLabel" \| "fieldDescription" \| "error", string>>` |  | Extra classes for the parts. label and description style each option's text; fieldLabel and fieldDescription the group's own label and hint; group is the radiogroup element, option each row or card, body the inner row of a card (the part that stacks when the card is narrow), item the circle. |
| `className?` | `string` |  | Classes for the outermost element: the Field wrapper when there is a label, description or error, otherwise the radiogroup. Override the tone vars here, e.g. [--tone:#7C3AED]. |
| `ref?` | `RefObject<HTMLDivElement \| null> or (node) => void` |  | The radiogroup element. Every other prop (aria-label, dir, form, data-*) lands there too. |

## Parts

### RadioGroupRoot (component)

The group without the options array, for layouts an array cannot express: plan cards with feature lists, mixed rows. Takes every RadioGroup prop except options, plus RadioGroupItem children.

### RadioGroupItem (component)

One option inside RadioGroupRoot: a <label> row or card whose whole surface picks it. Children render under the description; other props and ref go on the label.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` |  | The value the group reports when this option is picked. |
| `label?` | `ReactNode` |  | The option's name. |
| `description?` | `ReactNode` |  | A muted line under the label. |
| `icon?` | `ReactNode` |  | A leading icon: muted beside a row, in a tinted tile on a card. |
| `badge?` | `ReactNode` |  | Trailing text on the label's line, like a price or a tag. |
| `disabled?` | `boolean` | `false` | Dims this option and skips it with the arrow keys. |
| `classNames?` | `Partial<Record<RadioItemSlot, string>>` |  | Extra classes for the option's inner parts. |
| `children?` | `ReactNode` |  | Extra content under the description, such as plan features. |

### RadioOption (type)

One entry of options: { value, label, description?, icon?, badge?, disabled? }.

## Examples

- **Payment method**: Rows with icons, a description and a trailing surcharge. The whole row is the target.
- **Horizontal**: Short choices side by side; they wrap onto the next line on a phone instead of scrolling.
- **Plan cards**: RadioGroupRoot with custom items in 3 columns, showControl={false} and the brand tone: the ring alone marks the plan.
- **Sizes, tones and a custom color**: 16, 18 and 20px circles; any tone or CSS color, with a dot or a check that draws itself.
- **Disabled and read-only**: A disabled option dims and the arrow keys skip it; a read-only group keeps its choice and stays focusable.
- **Required, checked on submit**: Submit with nothing chosen: the error slides in and focus moves to the group. Picking a card clears it.
- **Settings list**: controlPosition="end" and indicator="check" inside a list group, like a phone settings screen.
- **Delivery slots in two columns**: Small cards in a 2-column grid that falls to one column on phones; a full slot is disabled.

## See also

- [Checkbox](https://wingo-ui.com/components/checkbox) ([Markdown](https://wingo-ui.com/components/checkbox.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Toggle Group](https://wingo-ui.com/components/toggle-group) ([Markdown](https://wingo-ui.com/components/toggle-group.md))
- [Switch](https://wingo-ui.com/components/switch) ([Markdown](https://wingo-ui.com/components/switch.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))

---

Canonical: https://wingo-ui.com/components/radio-group
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
