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.

Installation

1 Configure registry — once per project
{
  "registries": {
    "@designrevision": "https://registry.designrevision.com/r/{name}.json"
  }
}

Add to your existing components.json. Requires shadcn/ui v2.3+.

2 Install the component

Packages

@radix-ui/react-radio-group
lucide-react
utils

Props

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 = false
boolean

Disables the whole group, or an individual RadioGroupItem.

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

Copied!
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`.

Packages

radio-group
label

Props

No props documented yet.

Copied!
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.

Packages

radio-group
label

Props

No props documented yet.

Copied!
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.

Packages

radio-group
label

Props

No props documented yet.

Copied!
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.

Packages

react-hook-form
zod
@hookform/resolvers
radio-group
label
button

Props

No props documented yet.

Copied!
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.

Packages

radio-group
label

Props

No props documented yet.

Copied!
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`).

Packages

radio-group
label

Props

No props documented yet.

Copied!
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-*`.

Packages

radio-group
label

Props

No props documented yet.

Copied!
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>
  )
}

Frequently asked questions

Yes — it is the canonical shadcn/ui radio group built on Radix, with the same tokens and aria-invalid error handling, so it drops into any shadcn/ui project. On top of it we ship the patterns the original leaves out: radio cards, React Hook Form, horizontal layout, and sizes.
Use a radio group to pick exactly one option from a small set that is worth showing all at once (2–5 choices). Use a [checkbox](/components/checkbox) when each option is independent and any number can be on. Use a select when the list is long enough that showing every option would be unwieldy.
Wrap each RadioGroupItem in a Label styled as a card, then highlight the selected one with has-[[data-state=checked]]:border-primary on that Label. Clicking anywhere on the card selects it. The Radio cards example is the complete, copy-pasteable version.
The group uses onValueChange, which receives the selected item value as a string. Store it in state for a controlled group, or set defaultValue for an uncontrolled one. Each RadioGroupItem needs a unique value prop.
The group defaults to a vertical grid. Override the RadioGroup className to flex (for example flex flex-wrap gap-6) to lay the options out in a row. The Horizontal example shows this.
Yes. It is styled with CSS variable tokens like --primary, --input, and --ring, so it follows your theme automatically, including dark mode and any palette from a shadcn-compatible theme generator.