# Shadcn Radio Group

> Free shadcn/ui radio group component for React — radio cards / plan picker, React Hook Form validation, horizontal layout, descriptions and sizes. Copy or install in one command.

Source: https://designrevision.com/components/radio-group

The **Shadcn Radio Group** is the control for picking **one** option from a small set — the canonical <a href="https://ui.shadcn.com/docs/components/radio-group" rel="nofollow">shadcn/ui</a> radio group built on <a href="https://www.radix-ui.com/primitives/docs/components/radio-group" rel="nofollow">Radix</a>, themed entirely with CSS variables. It exports `RadioGroup` and `RadioGroupItem`, pairs with a [Label](/components/label) per option, and the examples below cover what you actually build — radio cards, validated forms, and horizontal layouts.

## When to use — radio vs checkbox vs select

Reach for a radio group when the user must choose **exactly one** option from a small set (2–5) that's worth seeing all at once — a plan, a shipping method, a notification frequency. If each option is **independent** and any number can be on, use a [checkbox](/components/checkbox). If the list is long enough that showing every option would crowd the page, use a select. A radio group's strength is that every choice is visible and comparable.

## Radio cards

The highest-value radio pattern is the **card / plan picker**: each option is a whole clickable card with a title, description, and maybe a price, and the selected card highlights. Wrap each `RadioGroupItem` in a [Label](/components/label) styled as a card and highlight the chosen one with `has-[[data-state=checked]]:border-primary`. Clicking anywhere on the card selects it. The Radio cards example is the drop-in version.

## Descriptions and layout

For richer options, add a description line under each label — the "Standard / Recommended" pattern. The group lays out vertically by default (`grid gap-3`); override the `RadioGroup` className to `flex` for a **horizontal** row of short choices. Keep each `RadioGroupItem` bound to its own `Label` with `htmlFor`/`id` either way.

## React Hook Form

The group is fully controllable, so it slots into <a href="https://react-hook-form.com" rel="nofollow">React Hook Form</a>: read each group's value from the field and write it back with `setValue` (or `field.onChange`) from `onValueChange`. Pair it with a `zod` `enum` schema to require a selection, and set `aria-invalid` on the items to show the error. The React Hook Form example is a complete, working plan selector.

## Accessibility

Radix manages roving focus and arrow-key navigation across the group, so keyboard users move between options with the arrow keys and Tab in/out of the group. Give every option a bound `Label`, and for an invalid group keep the error message linked with `aria-describedby`. For a set of options under a shared question, a `fieldset` with a `legend` adds another layer of grouping for screen readers.

## Theming

The radio group reads your design tokens (`--primary`, `--input`, `--ring`), so it follows your theme — including dark mode — with zero overrides. Any palette produced by a shadcn-compatible theme generator applies automatically.

## Installation

### radio-group

**Install:**

```bash
npx shadcn@latest add @designrevision/radio-group
```

**Dependencies:** @radix-ui/react-radio-group, lucide-react, utils

**Props**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value | string | — | Controlled value of the selected item (on RadioGroup). |
| defaultValue | string | — | Initial selected value when uncontrolled (on RadioGroup). |
| onValueChange | (value: string) => void | — | Fires when the selection changes (on RadioGroup). |
| disabled | boolean | false | Disables the whole group, or an individual RadioGroupItem. |

**Usage & accessibility:** Two exports: RadioGroup (the Radix Root) and RadioGroupItem (each option, takes a required value). Set aria-invalid on items for the destructive error styling.

```tsx
// components/ui/radio-group.tsx
"use client"

import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"

import { cn } from "@/lib/utils"

function RadioGroup({
  className,
  ...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
  return (
    <RadioGroupPrimitive.Root
      data-slot="radio-group"
      className={cn("grid gap-3", className)}
      {...props}
    />
  )
}

function RadioGroupItem({
  className,
  ...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
  return (
    <RadioGroupPrimitive.Item
      data-slot="radio-group-item"
      className={cn(
        "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
        className
      )}
      {...props}
    >
      <RadioGroupPrimitive.Indicator
        data-slot="radio-group-indicator"
        className="relative flex items-center justify-center"
      >
        <CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
      </RadioGroupPrimitive.Indicator>
    </RadioGroupPrimitive.Item>
  )
}

export { RadioGroup, RadioGroupItem }
```

## Examples

### With labels

A controlled radio group with one labelled option per row — the canonical single-choice field, read with `onValueChange`.

**Install:**

```bash
npx shadcn@latest add @designrevision/radio-label-01
```

**Dependencies:** radio-group, label

```tsx
// components/ui/radio-label-01.tsx
"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "all", label: "All new messages" },
  { value: "mentions", label: "Direct messages and mentions" },
  { value: "none", label: "Nothing" },
]

export default function RadioLabel01() {
  const [value, setValue] = React.useState("all")

  return (
    <RadioGroup
      value={value}
      onValueChange={setValue}
      className="w-full max-w-sm"
    >
      {options.map((option) => (
        <div key={option.value} className="flex items-center gap-3">
          <RadioGroupItem id={option.value} value={option.value} />
          <Label htmlFor={option.value} className="font-normal">
            {option.label}
          </Label>
        </div>
      ))}
    </RadioGroup>
  )
}
```

---

### With descriptions

Each option carries a title and a description line — the “Standard / Recommended” pattern for plans and settings.

**Install:**

```bash
npx shadcn@latest add @designrevision/radio-description-01
```

**Dependencies:** radio-group, label

```tsx
// components/ui/radio-description-01.tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const plans = [
  {
    value: "starter",
    title: "Starter",
    description: "For individuals and small projects.",
  },
  {
    value: "team",
    title: "Team",
    description: "For growing teams that collaborate.",
  },
  {
    value: "enterprise",
    title: "Enterprise",
    description: "Advanced controls and dedicated support.",
  },
]

export default function RadioDescription01() {
  return (
    <RadioGroup defaultValue="team" className="w-full max-w-sm gap-4">
      {plans.map((plan) => (
        <div key={plan.value} className="flex items-start gap-3">
          <RadioGroupItem id={plan.value} value={plan.value} className="mt-0.5" />
          <div className="grid gap-1">
            <Label htmlFor={plan.value}>{plan.title}</Label>
            <p className="text-sm text-muted-foreground">{plan.description}</p>
          </div>
        </div>
      ))}
    </RadioGroup>
  )
}
```

---

### Radio cards

Whole-card selectable options — a plan picker that highlights the chosen card via `has-[[data-state=checked]]`. The dominant real-world radio UI.

**Install:**

```bash
npx shadcn@latest add @designrevision/radio-cards-01
```

**Dependencies:** radio-group, label

```tsx
// components/ui/radio-cards-01.tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const plans = [
  { value: "starter", title: "Starter", description: "Up to 5 projects", price: "$0" },
  { value: "pro", title: "Pro", description: "Unlimited projects", price: "$12" },
  { value: "team", title: "Team", description: "Pro plus collaboration", price: "$32" },
]

export default function RadioCards01() {
  return (
    <RadioGroup defaultValue="pro" className="w-full max-w-sm gap-3">
      {plans.map((plan) => (
        <Label
          key={plan.value}
          htmlFor={plan.value}
          className="flex items-center gap-3 rounded-lg border border-border p-4 font-normal has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/5"
        >
          <RadioGroupItem id={plan.value} value={plan.value} />
          <div className="grid gap-1">
            <span className="text-sm font-medium leading-none">{plan.title}</span>
            <span className="text-sm text-muted-foreground">
              {plan.description}
            </span>
          </div>
          <span className="ml-auto text-sm font-medium">
            {plan.price}
            <span className="text-muted-foreground">/mo</span>
          </span>
        </Label>
      ))}
    </RadioGroup>
  )
}
```

---

### React Hook Form

A required plan selection validated with `react-hook-form` and `zod` — the group reads its value from the field and writes back through `onValueChange`, with an `aria-invalid` error.

**Install:**

```bash
npx shadcn@latest add @designrevision/radio-form-01
```

**Dependencies:** react-hook-form, zod, @hookform/resolvers, radio-group, label, button

```tsx
// components/ui/radio-form-01.tsx
"use client"

import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const schema = z.object({
  plan: z.enum(["starter", "pro", "team"], {
    message: "Please select a plan.",
  }),
})

type FormValues = z.infer<typeof schema>

const plans = [
  { value: "starter", label: "Starter" },
  { value: "pro", label: "Pro" },
  { value: "team", label: "Team" },
]

export default function RadioForm01() {
  const [submitted, setSubmitted] = React.useState(false)
  const {
    handleSubmit,
    setValue,
    watch,
    formState: { errors },
  } = useForm<FormValues>({ resolver: zodResolver(schema) })

  const plan = watch("plan")

  return (
    <form
      onSubmit={handleSubmit(() => setSubmitted(true))}
      className="grid w-full max-w-sm gap-4"
    >
      <div className="grid gap-2">
        <Label>Subscription plan</Label>
        <RadioGroup
          value={plan}
          onValueChange={(value) =>
            setValue("plan", value as FormValues["plan"], {
              shouldValidate: true,
            })
          }
          aria-describedby={errors.plan ? "plan-error" : undefined}
        >
          {plans.map((option) => (
            <div key={option.value} className="flex items-center gap-3">
              <RadioGroupItem
                id={`plan-${option.value}`}
                value={option.value}
                aria-invalid={!!errors.plan}
              />
              <Label htmlFor={`plan-${option.value}`} className="font-normal">
                {option.label}
              </Label>
            </div>
          ))}
        </RadioGroup>
        {errors.plan ? (
          <p id="plan-error" className="text-sm text-destructive">
            {errors.plan.message}
          </p>
        ) : null}
      </div>
      <Button type="submit" className="w-fit">
        {submitted ? "Submitted!" : "Continue"}
      </Button>
    </form>
  )
}
```

---

### Horizontal

Lay the options out in a row by overriding the group to `flex` — useful for short, scannable choices.

**Install:**

```bash
npx shadcn@latest add @designrevision/radio-horizontal-01
```

**Dependencies:** radio-group, label

```tsx
// components/ui/radio-horizontal-01.tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "monthly", label: "Monthly" },
  { value: "yearly", label: "Yearly" },
  { value: "lifetime", label: "Lifetime" },
]

export default function RadioHorizontal01() {
  return (
    <RadioGroup defaultValue="yearly" className="flex flex-wrap gap-6">
      {options.map((option) => (
        <div key={option.value} className="flex items-center gap-2">
          <RadioGroupItem id={`billing-${option.value}`} value={option.value} />
          <Label htmlFor={`billing-${option.value}`} className="font-normal">
            {option.label}
          </Label>
        </div>
      ))}
    </RadioGroup>
  )
}
```

---

### States

A fully disabled group, a single disabled option, and an invalid group (`aria-invalid` switches on the destructive ring, paired with `aria-describedby`).

**Install:**

```bash
npx shadcn@latest add @designrevision/radio-states-01
```

**Dependencies:** radio-group, label

```tsx
// components/ui/radio-states-01.tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export default function RadioStates01() {
  return (
    <div className="grid w-full max-w-sm gap-6">
      <div className="grid gap-2">
        <Label className="text-muted-foreground">Disabled group</Label>
        <RadioGroup defaultValue="b" disabled>
          <div className="flex items-center gap-3">
            <RadioGroupItem id="dis-a" value="a" />
            <Label htmlFor="dis-a" className="font-normal text-muted-foreground">
              Option A
            </Label>
          </div>
          <div className="flex items-center gap-3">
            <RadioGroupItem id="dis-b" value="b" />
            <Label htmlFor="dis-b" className="font-normal text-muted-foreground">
              Option B
            </Label>
          </div>
        </RadioGroup>
      </div>
      <div className="grid gap-2">
        <Label>One option disabled</Label>
        <RadioGroup defaultValue="free">
          <div className="flex items-center gap-3">
            <RadioGroupItem id="opt-free" value="free" />
            <Label htmlFor="opt-free" className="font-normal">
              Free
            </Label>
          </div>
          <div className="flex items-center gap-3">
            <RadioGroupItem id="opt-pro" value="pro" disabled />
            <Label htmlFor="opt-pro" className="font-normal text-muted-foreground">
              Pro — upgrade required
            </Label>
          </div>
        </RadioGroup>
      </div>
      <div className="grid gap-2">
        <Label>Required — pick one</Label>
        <RadioGroup aria-describedby="radio-error">
          <div className="flex items-center gap-3">
            <RadioGroupItem id="inv-yes" value="yes" aria-invalid />
            <Label htmlFor="inv-yes" className="font-normal">
              Yes
            </Label>
          </div>
          <div className="flex items-center gap-3">
            <RadioGroupItem id="inv-no" value="no" aria-invalid />
            <Label htmlFor="inv-no" className="font-normal">
              No
            </Label>
          </div>
        </RadioGroup>
        <p id="radio-error" className="text-sm text-destructive">
          This field is required.
        </p>
      </div>
    </div>
  )
}
```

---

### Sizes

Small, default, and large radios — scale the control and its dot together with `size-* [&_svg]:size-*`.

**Install:**

```bash
npx shadcn@latest add @designrevision/radio-sizes-01
```

**Dependencies:** radio-group, label

```tsx
// components/ui/radio-sizes-01.tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export default function RadioSizes01() {
  return (
    <RadioGroup defaultValue="default" className="flex flex-wrap items-center gap-8">
      <div className="flex items-center gap-2">
        <RadioGroupItem
          id="rs-sm"
          value="sm"
          className="size-3.5 [&_svg]:size-1.5"
        />
        <Label htmlFor="rs-sm" className="text-xs font-normal">
          Small
        </Label>
      </div>
      <div className="flex items-center gap-2">
        <RadioGroupItem id="rs-default" value="default" />
        <Label htmlFor="rs-default" className="font-normal">
          Default
        </Label>
      </div>
      <div className="flex items-center gap-2">
        <RadioGroupItem
          id="rs-lg"
          value="lg"
          className="size-5 [&_svg]:size-2.5"
        />
        <Label htmlFor="rs-lg" className="text-base font-normal">
          Large
        </Label>
      </div>
    </RadioGroup>
  )
}
```
