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.
Installation
{
"registries": {
"@designrevision": "https://registry.designrevision.com/r/{name}.json"
}
}
Add to your existing components.json. Requires shadcn/ui v2.3+.
Packages
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.
"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
Props
No props documented yet.
"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
Props
No props documented yet.
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
Props
No props documented yet.
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
Props
No props documented yet.
"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
Props
No props documented yet.
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
Props
No props documented yet.
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
Props
No props documented yet.
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>
)
}
The Shadcn Radio Group is the control for picking one option from a small set — the canonical shadcn/ui radio group built on Radix, themed entirely with CSS variables. It exports RadioGroup and RadioGroupItem, pairs with a 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. 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 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 React Hook Form: 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.