Radio group
Radios as a list or as choice cards, with arrow-key navigation.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/radio-group.jsoncomponents/examples/radio-group-demo.tsx
"use client";
import * as React from "react";
import { RadioGroup } from "@/components/ui/radio-group";
export default function RadioGroupDemo() {
const [plan, setPlan] = React.useState<"starter" | "team" | "business">("team");
const [notify, setNotify] = React.useState<"all" | "mentions" | "none">("mentions");
return (
<div className="grid w-full max-w-2xl gap-8 sm:grid-cols-2">
<RadioGroup aria-label="Plan" variant="cards" value={plan} onValueChange={setPlan} options={[
{ value: "starter", label: "Starter · $0", description: "1 project, community support" },
{ value: "team", label: "Team · $29/seat", description: "Unlimited projects, alerts" },
{ value: "business", label: "Business · $99/seat", description: "SSO, audit log, SLA" },
]} />
<RadioGroup aria-label="Notifications" value={notify} onValueChange={setNotify} options={[
{ value: "all", label: "All activity" },
{ value: "mentions", label: "Mentions only", description: "Recommended" },
{ value: "none", label: "Nothing" },
]} />
</div>
);
}When to use
- One choice from a few options that need descriptions, like plans or shipping speed.
When not to use
- Short labels: a segmented control is more compact.
- Long lists: use a select.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/radio-group.json1Install the dependencies
npm install motion2Copy the source into your project
components/ui/radio-group.tsx"use client"; import * as React from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface RadioOption<T extends string> { value: T; label: React.ReactNode; description?: React.ReactNode; disabled?: boolean; } export interface RadioGroupProps<T extends string> { options: readonly RadioOption<T>[]; value?: T; defaultValue?: T; onValueChange?: (value: T) => void; /** "list" for plain radios, "cards" for bordered choice cards. */ variant?: "list" | "cards"; className?: string; "aria-label"?: string; } export function RadioGroup<T extends string>({ options, value, defaultValue, onValueChange, variant = "list", className, ...aria }: RadioGroupProps<T>) { const [internal, setInternal] = React.useState<T | undefined>(defaultValue); const current = value ?? internal; const refs = React.useRef<(HTMLButtonElement | null)[]>([]); const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0); const select = (v: T) => { if (value === undefined) setInternal(v); onValueChange?.(v); }; const onKeyDown = (e: React.KeyboardEvent, i: number) => { const pos = enabled.indexOf(i); const delta = e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 0; if (!delta) return; e.preventDefault(); const next = enabled[(pos + delta + enabled.length) % enabled.length]; select(options[next].value); refs.current[next]?.focus(); }; const focusIndex = Math.max(0, options.findIndex((o) => o.value === current)); return ( <div role="radiogroup" aria-label={aria["aria-label"]} className={cn(variant === "cards" ? "grid gap-2" : "space-y-2.5", className)}> {options.map((o, i) => { const checked = o.value === current; return ( <button key={o.value} ref={(el) => { refs.current[i] = el; }} type="button" role="radio" aria-checked={checked} disabled={o.disabled} tabIndex={i === focusIndex ? 0 : -1} onClick={() => select(o.value)} onKeyDown={(e) => onKeyDown(e, i)} className={cn( "flex w-full items-start gap-3 text-left text-sm outline-none disabled:opacity-40", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", variant === "cards" ? cn("rounded-lg border p-3.5 transition-colors", checked ? "border-foreground bg-muted/50" : "border-border hover:bg-muted/30") : "rounded-md", )} > <span className={cn("mt-0.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors", checked ? "border-primary" : "border-input")}> {checked && <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 600, damping: 28 }} className="size-2 rounded-full bg-primary" />} </span> <span className="flex-1"> <span className="block font-medium">{o.label}</span> {o.description && <span className="mt-0.5 block text-xs text-muted-foreground">{o.description}</span>} </span> </button> ); })} </div> ); }
Usage
Import it from @/components/ui/radio-group after installing.
example.tsx
import { RadioGroup } from "@/components/ui/radio-group";
export function Shipping() {
return (
<RadioGroup
aria-label="Shipping"
variant="cards"
defaultValue="standard"
options={[
{ value: "standard", label: "Standard", description: "3–5 days · Free" },
{ value: "express", label: "Express", description: "1–2 days · $12" },
]}
/>
);
}API reference
RadioGroup
Radios as a list or as choice cards.
| Prop | Type | Default | Description |
|---|---|---|---|
| options | readonly { value: T; label; description?; disabled? }[] | – | Choices. |
| value / defaultValue / onValueChange | T | – | Controlled or uncontrolled. |
| variant | "list" | "cards" | "list" | Plain radios or bordered cards. |
Keyboard interactions
| Keys | Action |
|---|---|
| ↑or↓or←or→ | Move and select |
| Tab | Enter or leave the group |
Accessibility
- role="radiogroup" with role="radio" items and roving tabindex.
- Disabled options are skipped.
Motion
- The dot scales in on select.
Responsive behavior
- Cards stack; put them in a grid for wider screens.
Performance
- Static markup.
Notes for AI
- Always pass aria-label or a visible group label.
This page as Markdown, for assistants and coding agents:
/components/radio-group/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt