Radio group
One choice from a short list of options, each a round mark on the card plane.
Vibra puts each mark on the card plane with its --input boundary in both modes, gives it the kit's solid focus outline, and fades the dot in over --duration-fast instead of letting it pop; the chosen mark fills in the accent through --primary. A disabled mark and a Label beside it dim through Base UI's data-disabled — the mark is a span, which is never :disabled. Name the group from a legend: a fieldset around it and aria-labelledby on the RadioGroup.
Install
npx shadcn@latest add @vibra/radio-groupNeeds the @vibra registry in your components.json — set it up once.
Examples
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const CADENCES = [
{ value: "daily", label: "Daily", hint: "Every morning at 08:00" },
{ value: "weekly", label: "Weekly", hint: "Mondays at 08:00" },
{ value: "monthly", label: "Monthly", hint: "On the 1st" },
]
export default function RadioGroupDemo() {
return (
<FieldSet className="w-full max-w-xs gap-3">
<FieldLegend id="digest-cadence" variant="label">
Revenue digest
</FieldLegend>
<RadioGroup aria-labelledby="digest-cadence" defaultValue="weekly">
{CADENCES.map((cadence) => (
<div key={cadence.value} className="flex items-start gap-3">
<RadioGroupItem id={`digest-${cadence.value}`} value={cadence.value} aria-describedby={`digest-${cadence.value}-hint`} />
<div className="flex flex-col gap-0.5">
<Label htmlFor={`digest-${cadence.value}`}>{cadence.label}</Label>
<span id={`digest-${cadence.value}-hint`} className="text-xs text-muted-foreground">
{cadence.hint}
</span>
</div>
</div>
))}
</RadioGroup>
</FieldSet>
)
}Side by side
A few short options in one row that wraps on a phone; the arrow keys still move the choice.
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const FORMATS = [
["csv", "CSV"],
["xlsx", "XLSX"],
["pdf", "PDF"],
] as const
// A few short options in one row. The arrow keys still move the choice left
// and right, and the row wraps on a phone.
export default function RadioGroupInline() {
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend id="radio-group-inline-legend" variant="label">
Export the September ledger as
</FieldLegend>
<RadioGroup aria-labelledby="radio-group-inline-legend" defaultValue="csv" className="flex flex-wrap gap-x-6 gap-y-3">
{FORMATS.map(([value, label]) => (
<div key={value} className="flex items-center gap-2">
<RadioGroupItem id={`radio-group-inline-${value}`} value={value} />
<Label htmlFor={`radio-group-inline-${value}`} className="font-normal">
{label}
</Label>
</div>
))}
</RadioGroup>
</FieldSet>
)
}Disabled option, with a reason
An expired card can't be chosen and says why under it; the arrow keys pass over it, and the reason keeps its contrast.
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const METHODS = [
{ value: "card-4242", label: "Card ending 4242", hint: "Expires 08/27" },
{ value: "card-1881", label: "Card ending 1881", hint: "Expired in June. Update it under Billing.", disabled: true },
{ value: "transfer", label: "Bank transfer", hint: "We send the invoice; you pay within 30 days." },
]
// One option that can't be chosen, and says why under it. The arrow keys pass
// over it; its dot and label dim, and the reason keeps its contrast.
export default function RadioGroupDisabled() {
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend id="radio-group-disabled-legend" variant="label">
Pay renewals with
</FieldLegend>
<RadioGroup aria-labelledby="radio-group-disabled-legend" defaultValue="card-4242">
{METHODS.map((method) => (
<div key={method.value} className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-0.5">
<RadioGroupItem
id={`radio-group-disabled-${method.value}`}
value={method.value}
disabled={method.disabled}
aria-describedby={`radio-group-disabled-${method.value}-hint`}
/>
<Label htmlFor={`radio-group-disabled-${method.value}`}>{method.label}</Label>
<span id={`radio-group-disabled-${method.value}-hint`} className="col-start-2 text-xs text-muted-foreground">
{method.hint}
</span>
</div>
))}
</RadioGroup>
</FieldSet>
)
}Required, with an error
No safe default, so nothing is chosen. Pressing on without an answer marks the group invalid once and moves the focus into it.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { FieldError, FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const REASONS = [
["duplicate", "Charged twice"],
["fraudulent", "Card used without permission"],
["requested", "The customer asked"],
] as const
// Nothing is chosen for the reader: the question has no safe default. Pressing
// on without an answer marks the group invalid, says so once, and moves the
// focus into the group.
export default function RadioGroupRequired() {
const first = React.useRef<HTMLElement>(null)
const [reason, setReason] = React.useState<string | null>(null)
const [error, setError] = React.useState(false)
const [done, setDone] = React.useState(false)
return (
<form
noValidate
className="flex w-full max-w-sm flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
if (!reason) {
setError(true)
first.current?.focus()
return
}
setDone(true)
}}
>
<FieldSet className="gap-3">
<FieldLegend id="radio-group-required-legend" variant="label">
Why refund INV-2041? <span className="font-normal text-muted-foreground">(required)</span>
</FieldLegend>
<RadioGroup
aria-labelledby="radio-group-required-legend"
aria-describedby={error ? "radio-group-required-error" : undefined}
aria-invalid={error || undefined}
required
value={reason}
onValueChange={(value) => {
setReason(value as string)
setError(false)
}}
>
{REASONS.map(([value, label], index) => (
<div key={value} className="flex items-center gap-3">
<RadioGroupItem
ref={index === 0 ? first : undefined}
id={`radio-group-required-${value}`}
value={value}
aria-invalid={error || undefined}
aria-describedby={error ? "radio-group-required-error" : undefined}
/>
<Label htmlFor={`radio-group-required-${value}`} className="font-normal">
{label}
</Label>
</div>
))}
</RadioGroup>
<FieldError id="radio-group-required-error">{error ? "Choose why, so the bank sees the right reason." : null}</FieldError>
</FieldSet>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={done} focusableWhenDisabled>
Refund $1,280.00
</Button>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{done ? "Refund sent to Blue Harbor Logistics." : null}
</p>
</div>
</form>
)
}With an "other" field
"Something else" opens its own labelled field under it, as the next Tab stop, without taking the focus.
"use client"
import * as React from "react"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const REASONS = [
["price", "It costs too much"],
["feature", "It's missing something we need"],
["switching", "We're moving to another tool"],
["other", "Something else"],
] as const
// "Something else" opens a field of its own, labelled, right under it. Choosing
// it doesn't steal the focus; the field is the next Tab stop.
export default function RadioGroupOther() {
const [reason, setReason] = React.useState<string>("other")
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend id="radio-group-other-legend" variant="label">
Why are you cancelling?
</FieldLegend>
<RadioGroup aria-labelledby="radio-group-other-legend" value={reason} onValueChange={(value) => setReason(value as string)}>
{REASONS.map(([value, label]) => (
<div key={value} className="flex items-center gap-3">
<RadioGroupItem
id={`radio-group-other-${value}`}
value={value}
aria-controls={value === "other" ? "radio-group-other-more" : undefined}
/>
<Label htmlFor={`radio-group-other-${value}`} className="font-normal">
{label}
</Label>
</div>
))}
</RadioGroup>
<div id="radio-group-other-more" hidden={reason !== "other"} className="ms-7 flex flex-col gap-2">
<Label htmlFor="radio-group-other-detail">Tell us more</Label>
<Input id="radio-group-other-detail" autoComplete="off" defaultValue="We only needed the Q3 board report." />
</div>
</FieldSet>
)
}A filter with counts
A sidebar filter that applies at once: each option carries its count, the chosen row is a tint, and a status line says what shows.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const STATUSES = [
{ value: "all", label: "All", count: 1284 },
{ value: "paid", label: "Paid", count: 1102 },
{ value: "overdue", label: "Overdue", count: 46 },
{ value: "draft", label: "Draft", count: 136 },
]
const number = new Intl.NumberFormat("en-US")
// A filter in a sidebar: the choice applies at once, each option carries its
// count in the table's figures, and a status line says what the table shows.
export default function RadioGroupCounts() {
const [status, setStatus] = React.useState("overdue")
const chosen = STATUSES.find((option) => option.value === status)!
return (
<FieldSet className="w-full max-w-60 gap-2">
<FieldLegend id="radio-group-counts-legend" variant="label">
Invoice status
</FieldLegend>
<RadioGroup aria-labelledby="radio-group-counts-legend" value={status} onValueChange={(value) => setStatus(value as string)} className="gap-0.5">
{STATUSES.map((option) => (
<div key={option.value} className={cn("flex items-center gap-3 rounded-md px-2 py-1.5", status === option.value && "bg-brand-muted")}>
<RadioGroupItem id={`radio-group-counts-${option.value}`} value={option.value} />
<Label htmlFor={`radio-group-counts-${option.value}`} className={cn("flex-1 justify-between", status === option.value ? "font-medium" : "font-normal")}>
{option.label}{" "}
<span className="text-muted-foreground tabular-nums">{number.format(option.count)}</span>
</Label>
</div>
))}
</RadioGroup>
<p role="status" aria-live="polite" className="px-2 text-xs text-muted-foreground">
Showing {number.format(chosen.count)} {chosen.value === "all" ? "invoices" : `${chosen.label.toLowerCase()} invoices`}.
</p>
</FieldSet>
)
}Drives a summary
The choice changes a figure elsewhere, so the figure is a status line, read out when it changes.
"use client"
import * as React from "react"
import { Badge } from "@/components/ui/badge"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const SEATS = 12
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
const CYCLES = {
monthly: { label: "Monthly", perSeat: 29, months: 1 },
yearly: { label: "Yearly", perSeat: 24, months: 12 },
} as const
// The choice changes a figure elsewhere, so the figure sits in a status line
// and is read out when it changes — the reader hears what the choice costs.
export default function RadioGroupSummary() {
const [cycle, setCycle] = React.useState<keyof typeof CYCLES>("yearly")
const chosen = CYCLES[cycle]
const charge = chosen.perSeat * SEATS * chosen.months
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend id="radio-group-summary-legend" variant="label">
Billing cycle for {SEATS} seats
</FieldLegend>
<RadioGroup aria-labelledby="radio-group-summary-legend" value={cycle} onValueChange={(value) => setCycle(value as keyof typeof CYCLES)}>
{(Object.keys(CYCLES) as (keyof typeof CYCLES)[]).map((key) => (
<div key={key} className="flex items-center gap-3">
<RadioGroupItem id={`radio-group-summary-${key}`} value={key} />
<Label htmlFor={`radio-group-summary-${key}`} className="font-normal">
<span>
{CYCLES[key].label}, <span className="tabular-nums">{money.format(CYCLES[key].perSeat)}</span> a seat a month
</span>
{key === "yearly" ? <Badge variant="secondary">Save 17%</Badge> : null}
</Label>
</div>
))}
</RadioGroup>
<p role="status" aria-live="polite" className="rounded-md bg-surface px-3 py-2 text-sm">
You pay <span className="font-medium tabular-nums">{money.format(charge)}</span> {cycle === "yearly" ? "a year, on 1 October" : "a month, from 1 October"}.
</p>
</FieldSet>
)
}Plan cards
Plans side by side from md up. The title names each radio, the price and line describe it, and a badge marks the current plan.
import { Badge } from "@/components/ui/badge"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet, FieldTitle } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const PLANS = [
{ value: "starter", title: "Starter", price: "$0", unit: "for 3 seats", hint: "Dashboards and a weekly digest." },
{ value: "growth", title: "Growth", price: "$29", unit: "a seat a month", hint: "Alerts, exports and SSO.", badge: "Current plan" },
{ value: "scale", title: "Scale", price: "$59", unit: "a seat a month", hint: "Audit log and data residency." },
]
// Plans side by side from md up, each card the radio's label: the title names
// it, the price and the line describe it, and the current plan says so in a
// badge — the tint is only for the one chosen. On a phone the line steps
// aside for the eye; the radio is still described by it.
export default function RadioGroupCards() {
return (
<FieldSet className="w-full max-w-2xl gap-3">
<FieldLegend id="radio-group-cards-legend" variant="label">
Plan
</FieldLegend>
<RadioGroup aria-labelledby="radio-group-cards-legend" defaultValue="growth" className="grid gap-3 md:grid-cols-3">
{PLANS.map((plan) => (
<FieldLabel key={plan.value} htmlFor={`radio-group-cards-${plan.value}`}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle id={`radio-group-cards-${plan.value}-title`}>
{plan.title}{" "}
{plan.badge ? <Badge variant="outline">{plan.badge}</Badge> : null}
</FieldTitle>
<FieldDescription>
<span id={`radio-group-cards-${plan.value}-price`}>
<span className="font-medium text-foreground tabular-nums">{plan.price}</span> {plan.unit}.
</span>{" "}
<span id={`radio-group-cards-${plan.value}-hint`} className="max-sm:hidden">
{plan.hint}
</span>
</FieldDescription>
</FieldContent>
<RadioGroupItem
id={`radio-group-cards-${plan.value}`}
value={plan.value}
aria-labelledby={`radio-group-cards-${plan.value}-title`}
aria-describedby={`radio-group-cards-${plan.value}-price radio-group-cards-${plan.value}-hint`}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</FieldSet>
)
}Tiles with icons
Square tiles for a choice easier seen than read: the icon draws it, the word names it, the radio sits in the corner.
import { ChartAreaIcon, ChartColumnIcon, ChartLineIcon } from "lucide-react"
import { Field, FieldLabel, FieldLegend, FieldSet, FieldTitle } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const CHARTS = [
{ value: "line", title: "Line", icon: ChartLineIcon },
{ value: "bar", title: "Bar", icon: ChartColumnIcon },
{ value: "area", title: "Area", icon: ChartAreaIcon },
]
// Square tiles for a choice that is easier seen than read. The icon draws the
// option and the word names it; the radio stays in the corner, where the
// arrow keys move it from tile to tile.
export default function RadioGroupTiles() {
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend id="radio-group-tiles-legend" variant="label">
Draw MRR as a
</FieldLegend>
<RadioGroup aria-labelledby="radio-group-tiles-legend" defaultValue="line" className="grid grid-cols-3 gap-3">
{CHARTS.map((chart) => (
<FieldLabel key={chart.value} htmlFor={`radio-group-tiles-${chart.value}`}>
<Field className="relative items-center gap-2 py-4 *:w-auto">
<RadioGroupItem id={`radio-group-tiles-${chart.value}`} value={chart.value} className="absolute top-2 end-2" />
<chart.icon aria-hidden="true" className="size-6 text-muted-foreground" />
<FieldTitle>{chart.title}</FieldTitle>
</Field>
</FieldLabel>
))}
</RadioGroup>
</FieldSet>
)
}A rating scale
Five figures in a row with the ends anchored in words; each radio's name carries its meaning, "4, good".
"use client"
import * as React from "react"
import { Field, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const SCORES = [
[1, "very poor"],
[2, "poor"],
[3, "fine"],
[4, "good"],
[5, "very good"],
] as const
// A five-point scale: the figures are the labels, the two ends are anchored in
// words under them, and each radio's name carries its meaning — "4, good" —
// so no one has to see where the ends are.
export default function RadioGroupScale() {
const [score, setScore] = React.useState<number>(4)
return (
<FieldSet className="w-full max-w-xs gap-3">
<FieldLegend id="radio-group-scale-legend" variant="label">
How was your chat with support?
</FieldLegend>
<RadioGroup
aria-labelledby="radio-group-scale-legend"
value={score}
onValueChange={(value) => setScore(value as number)}
className="grid grid-cols-5 gap-2"
>
{SCORES.map(([value, meaning]) => (
<FieldLabel key={value} htmlFor={`radio-group-scale-${value}`}>
<Field className="items-center gap-2 *:w-auto">
<span id={`radio-group-scale-${value}-name`} className="text-sm font-medium tabular-nums">
{value}
<span className="sr-only">, {meaning}</span>
</span>
<RadioGroupItem id={`radio-group-scale-${value}`} value={value} aria-labelledby={`radio-group-scale-${value}-name`} />
</Field>
</FieldLabel>
))}
</RadioGroup>
<div aria-hidden="true" className="flex justify-between text-xs text-muted-foreground">
<span>Very poor</span>
<span>Very good</span>
</div>
</FieldSet>
)
}A matrix
One radio group per row, named by the row, each radio named by its column head. Arrows move in a row; Tab moves between rows.
"use client"
import * as React from "react"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const LEVELS = [
["none", "No access"],
["view", "View"],
["edit", "Edit"],
] as const
const AREAS = [
["invoices", "Invoices", "edit"],
["customers", "Customers", "view"],
["reports", "Reports", "view"],
] as const
// One radio group per row: the row names its group and the column heads name
// the radios, so a screen reader hears "Customers, View, 2 of 3". Arrow keys
// move within a row; Tab moves between rows.
export default function RadioGroupMatrix() {
const [access, setAccess] = React.useState<Record<string, string>>(
Object.fromEntries(AREAS.map(([area, , level]) => [area, level]))
)
return (
<FieldSet className="w-full max-w-md gap-2">
<FieldLegend variant="label">What the Finance role can do</FieldLegend>
<div className="grid grid-cols-[1fr_repeat(3,4.5rem)] items-center text-sm">
<span />
{LEVELS.map(([level, label]) => (
<span key={level} id={`radio-group-matrix-${level}`} className="pb-2 text-center text-xs text-muted-foreground">
{label}
</span>
))}
{AREAS.map(([area, label]) => (
<React.Fragment key={area}>
<span id={`radio-group-matrix-${area}`} className="border-t border-border py-2.5 font-medium">
{label}
</span>
<RadioGroup
aria-labelledby={`radio-group-matrix-${area}`}
value={access[area]}
onValueChange={(value) => setAccess((current) => ({ ...current, [area]: value as string }))}
className="col-span-3 grid grid-cols-subgrid gap-0 border-t border-border py-2.5"
>
{LEVELS.map(([level]) => (
<RadioGroupItem
key={level}
value={level}
aria-labelledby={`radio-group-matrix-${level}`}
className="justify-self-center"
/>
))}
</RadioGroup>
</React.Fragment>
))}
</div>
</FieldSet>
)
}Right to left
The demo in Arabic: each mark at the right of its label, the chosen dot centred from the middle.
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const CADENCES = [
["daily", "يوميًا", "كل صباح الساعة 08:00"],
["weekly", "أسبوعيًا", "أيام الاثنين الساعة 08:00"],
["monthly", "شهريًا", "في اليوم الأول من الشهر"],
] as const
// The demo in Arabic: the dots sit at the right of their labels, and the dot
// inside a chosen mark stays centred — it is placed from the middle, not a side.
export default function RadioGroupRtl() {
return (
<FieldSet dir="rtl" lang="ar" className="w-full max-w-xs gap-3">
<FieldLegend id="radio-group-rtl-legend" variant="label">
ملخص الإيرادات
</FieldLegend>
<RadioGroup aria-labelledby="radio-group-rtl-legend" defaultValue="weekly">
{CADENCES.map(([value, label, hint]) => (
<div key={value} className="flex items-start gap-3">
<RadioGroupItem id={`radio-group-rtl-${value}`} value={value} aria-describedby={`radio-group-rtl-${value}-hint`} />
<div className="flex flex-col gap-0.5">
<Label htmlFor={`radio-group-rtl-${value}`}>{label}</Label>
<span id={`radio-group-rtl-${value}-hint`} className="text-xs text-muted-foreground">
{hint}
</span>
</div>
</div>
))}
</RadioGroup>
</FieldSet>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue | unknown | — | The chosen option's value, controlled or not; null chooses nothing. |
| onValueChange | (value: unknown) => void | — | Called with the newly chosen value. |
| required | boolean | false | An answer is needed before the form submits. Say "required" in the legend too. |
| RadioGroupItem.disabled | boolean | false | Takes one option out: skipped by the arrow keys and dimmed, with a Label beside it. |
Dependencies
Registry
Source
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
// Base UI renders a span, which is never :disabled — the disabled look
// answers data-disabled.
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input bg-card transition-[background-color,border-color] duration-(--duration-fast) ease-(--ease-standard) focus-ring group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center transition-opacity duration-(--duration-fast) ease-(--ease-standard) data-unchecked:opacity-0"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }