RadioGroup
Accessible radio fieldset with a simple list and a card variant.
import { RadioGroup, RadioOption }from "@/components/ui"
Specification
Selects exactly one option from a small mutually-exclusive set, as a simple list or as selectable cards.
Props (7)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| name* | string | — | Radio group name shared by all inputs so only one can be selected. |
| label | string | — | Optional fieldset legend shown above the options. |
| options* | RadioOption[] | — | Options to render; each has value, label and optional description and disabled. |
| value* | string | null | — | The value of the currently selected option, or null. |
| onChange* | (value: string) => void | — | Called with the selected option's value. |
| variant | "list" | "card" | "list" | Renders a compact radio list or larger selectable cards with descriptions. |
| disabled | boolean | — | Disables the whole group. |
List & cards
Full source
The complete component, read from
src/components/ui/radio-group.tsx.radio-group.tsx
"use client";
import { cn } from "@/lib/utils";
export interface RadioOption {
value: string;
label: string;
description?: string;
disabled?: boolean;
}
interface RadioGroupProps {
name: string;
label?: string;
options: RadioOption[];
value: string | null;
onChange: (value: string) => void;
variant?: "list" | "card";
disabled?: boolean;
}
export function RadioGroup({
name,
label,
options,
value,
onChange,
variant = "list",
disabled,
}: RadioGroupProps) {
if (variant === "card") {
return (
<fieldset className="min-w-0" disabled={disabled}>
{label && <legend className="mb-2 text-[13px] font-medium text-text">{label}</legend>}
<div className="grid gap-2">
{options.map((opt) => {
const on = opt.value === value;
const off = opt.disabled || disabled;
return (
<label
key={opt.value}
className={cn(
"flex cursor-pointer items-start gap-3 rounded-xl border p-4 transition-colors",
on ? "border-accent bg-accent-light" : "border-border bg-surface hover:border-border-hover",
"focus-within:border-accent",
off && "cursor-not-allowed opacity-50"
)}
>
<input
type="radio"
name={name}
value={opt.value}
checked={on}
disabled={off}
onChange={() => onChange(opt.value)}
className="sr-only"
/>
<span className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border-2",
on ? "border-accent" : "border-border"
)}>
{on && <span className="size-2 rounded-full bg-accent" />}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-text">{opt.label}</span>
{opt.description && (
<span className="mt-0.5 block text-sm text-text-secondary">{opt.description}</span>
)}
</span>
</label>
);
})}
</div>
</fieldset>
);
}
return (
<fieldset className="min-w-0" disabled={disabled}>
{label && <legend className="mb-2 text-[13px] font-medium text-text">{label}</legend>}
<div className="flex flex-col gap-2">
{options.map((opt) => {
const on = opt.value === value;
const off = opt.disabled || disabled;
return (
<label
key={opt.value}
className={cn(
"flex cursor-pointer items-center gap-2.5",
off && "cursor-not-allowed opacity-50"
)}
>
<input
type="radio"
name={name}
value={opt.value}
checked={on}
disabled={off}
onChange={() => onChange(opt.value)}
className="sr-only"
/>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
on ? "border-accent" : "border-border"
)}>
{on && <span className="size-2 rounded-full bg-accent" />}
</span>
<span className="text-sm text-text">{opt.label}</span>
</label>
);
})}
</div>
</fieldset>
);
}Dependencies
To use RadioGroup you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ └─ radio-group.tsx src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}