Radio Group
A set of mutually exclusive radio buttons for selecting one option from a group. Uses native radio inputs with shared name attributes for built-in keyboard navigation and accessibility.
Search across all documentation pages
A set of mutually exclusive radio buttons for selecting one option from a group. Uses native radio inputs with shared name attributes for built-in keyboard navigation and accessibility.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
interface RadioGroupProps {
label: string;
name: string;
options: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroup({ label, name, options, value, onChange }: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-2">
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}A minimal radio group using <fieldset> and <legend> for semantic grouping. All radios share the same name attribute, which lets the browser enforce mutual exclusivity and enables arrow-key navigation between options natively.
"use client";
interface Option {
value: string;
label: string;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
error?: string;
}
export function RadioGroup({ label, name, options, value, onChange, error }: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-2">
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={!!error}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</fieldset>
);
}A vertical stack of radio options with optional error state. The aria-invalid attribute on each radio input communicates validation problems to screen readers. The error message appears below the entire group.
"use client";
interface RadioGroupProps {
label: string;
name: string;
options: { value: string; label: string }[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroupHorizontal({ label, name, options, value, onChange }: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 flex flex-wrap gap-4">
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}Options are arranged in a horizontal row using flex and gap-4. The flex-wrap class ensures options wrap to the next line on narrow screens instead of overflowing. Best suited for groups with 2-4 short-labeled options.
"use client";
interface Option {
value: string;
label: string;
description: string;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroupWithDescriptions({
label,
name,
options,
value,
onChange,
}: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-3">
{options.map((opt) => (
<label
key={opt.value}
className="flex cursor-pointer items-start gap-3"
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<div>
<span className="text-sm font-medium text-gray-900">{opt.label}</span>
<p className="text-sm text-gray-500">{opt.description}</p>
</div>
</label>
))}
</div>
</fieldset>
);
}Each option has a secondary description below the label. The radio input is aligned to the top of the text block with items-start and mt-0.5. This pattern works well for plan selection or shipping options where each choice needs explanation.
"use client";
interface Option {
value: string;
label: string;
description: string;
price?: string;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroupCards({
label,
name,
options,
value,
onChange,
}: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-3">
{options.map((opt) => (
<label
key={opt.value}
className={`flex cursor-pointer items-start gap-3 rounded-lg border-2 p-4 transition-colors ${
value === opt.value
? "border-blue-600 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{opt.label}</span>
{opt.price && (
<span className="text-sm font-semibold text-gray-900">{opt.price}</span>
)}
</div>
<p className="mt-1 text-sm text-gray-500">{opt.description}</p>
</div>
</label>
))}
</div>
</fieldset>
);
}Wraps each radio option in a card with a visible border that highlights when selected. The selected card gets a blue border and light blue background. An optional price field is right-aligned in the header row. This pattern is common for pricing tiers or shipping method selection.
"use client";
interface Option {
value: string;
label: string;
icon: React.ReactNode;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroupWithIcons({
label,
name,
options,
value,
onChange,
}: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 grid grid-cols-3 gap-3">
{options.map((opt) => (
<label
key={opt.value}
className={`flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors ${
value === opt.value
? "border-blue-600 bg-blue-50 text-blue-600"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
className="sr-only"
/>
<span className="h-6 w-6">{opt.icon}</span>
<span className="text-sm font-medium">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}A grid of icon-centric radio cards with the native radio input visually hidden using sr-only. The entire card acts as the click target. The selected card is highlighted with a blue border and icon color. This works well for view mode toggles (grid/list), theme selection, or category pickers.
"use client";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface RadioGroupProps {
label: string;
name: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
export function RadioGroup({ label, name, options, value, onChange }: RadioGroupProps) {
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-2">
{options.map((opt) => (
<label
key={opt.value}
className={`flex items-center gap-2 ${
opt.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value)}
disabled={opt.disabled}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}Individual options can be disabled while others remain interactive. The opacity-50 class and cursor-not-allowed on the label provide clear visual feedback. Disabled radio buttons are skipped during keyboard navigation automatically by the browser.
"use client";
import { forwardRef, useId, createContext, useContext, useCallback } from "react";
type RadioSize = "sm" | "md" | "lg";
type RadioVariant = "default" | "card" | "icon";
interface RadioGroupContextValue {
name: string;
value: string;
onChange: (value: string) => void;
size: RadioSize;
variant: RadioVariant;
disabled: boolean;
}
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
function useRadioGroup() {
const ctx = useContext(RadioGroupContext);
if (!ctx) throw new Error("RadioGroupItem must be used within RadioGroup");
return ctx;
}
interface RadioGroupProps {
label?: string;
helperText?: string;
error?: string;
name: string;
value: string;
onChange: (value: string) => void;
size?: RadioSize;
variant?: RadioVariant;
disabled?: boolean;
orientation?: "vertical" | "horizontal";
children: React.ReactNode;
id?: string;
}
const sizeClasses: Record<RadioSize, { radio: string; label: string; desc: string }> = {
sm: { radio: "h-3.5 w-3.5", label: "text-xs", desc: "text-xs" },
md: { radio: "h-4 w-4", label: "text-sm", desc: "text-sm" },
lg: { radio: "h-5 w-5", label: "text-base", desc: "text-sm" },
};
export function RadioGroup({
label,
helperText,
error,
name,
value,
onChange,
size = "md",
variant = "default",
disabled = false,
orientation = "vertical",
children,
id: externalId,
}: RadioGroupProps) {
const generatedId = useId();
const groupId = externalId ?? generatedId;
const errorId = `${groupId}-error`;
const helperId = `${groupId}-helper`;
const sizes = sizeClasses[size];
return (
<RadioGroupContext.Provider value={{ name, value, onChange, size, variant, disabled }}>
<fieldset
aria-invalid={!!error}
aria-describedby={
[error ? errorId : null, helperText ? helperId : null]
.filter(Boolean)
.join(" ") || undefined
}
disabled={disabled}
>
{label && (
<legend className={`font-medium text-gray-700 ${sizes.label}`}>
{label}
</legend>
)}
<div
role="radiogroup"
className={[
"mt-2",
orientation === "horizontal" ? "flex flex-wrap gap-4" : "space-y-3",
].join(" ")}
>
{children}
</div>
{error && (
<p id={errorId} className={`mt-2 text-red-600 ${sizes.desc}`} role="alert">
{error}
</p>
)}
{!error && helperText && (
<p id={helperId} className={`mt-2 text-gray-500 ${sizes.desc}`}>
{helperText}
</p>
)}
</fieldset>
</RadioGroupContext.Provider>
);
}
interface RadioGroupItemProps {
value: string;
label: string;
description?: string;
icon?: React.ReactNode;
disabled?: boolean;
}
export const RadioGroupItem = forwardRef<HTMLInputElement, RadioGroupItemProps>(
function RadioGroupItem({ value: itemValue, label, description, icon, disabled: itemDisabled }, ref) {
const { name, value, onChange, size, variant, disabled: groupDisabled } = useRadioGroup();
const id = useId();
const descriptionId = `${id}-desc`;
const isDisabled = groupDisabled || itemDisabled;
const isSelected = value === itemValue;
const sizes = sizeClasses[size];
const handleChange = useCallback(() => {
if (!isDisabled) onChange(itemValue);
}, [isDisabled, onChange, itemValue]);
if (variant === "card") {
return (
<label
className={[
"flex items-start gap-3 rounded-lg border-2 p-4 transition-colors",
isDisabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer",
isSelected
? "border-blue-600 bg-blue-50"
: isDisabled
? "border-gray-200"
: "border-gray-200 hover:border-gray-300",
]
.filter(Boolean)
.join(" ")}
>
<input
ref={ref}
id={id}
type="radio"
name={name}
value={itemValue}
checked={isSelected}
onChange={handleChange}
disabled={isDisabled}
aria-describedby={description ? descriptionId : undefined}
className={`mt-0.5 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed ${sizes.radio}`}
/>
<div>
<span className={`font-medium text-gray-900 ${sizes.label}`}>{label}</span>
{description && (
<p id={descriptionId} className={`mt-0.5 text-gray-500 ${sizes.desc}`}>
{description}
</p>
)}
</div>
</label>
);
}
if (variant === "icon" && icon) {
return (
<label
className={[
"flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors",
isDisabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer",
isSelected
? "border-blue-600 bg-blue-50 text-blue-600"
: isDisabled
? "border-gray-200 text-gray-400"
: "border-gray-200 text-gray-500 hover:border-gray-300",
]
.filter(Boolean)
.join(" ")}
>
<input
ref={ref}
id={id}
type="radio"
name={name}
value={itemValue}
checked={isSelected}
onChange={handleChange}
disabled={isDisabled}
className="sr-only"
/>
<span className="h-6 w-6">{icon}</span>
<span className={`font-medium ${sizes.label}`}>{label}</span>
</label>
);
}
return (
<label
className={`flex items-start gap-3 ${
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<input
ref={ref}
id={id}
type="radio"
name={name}
value={itemValue}
checked={isSelected}
onChange={handleChange}
disabled={isDisabled}
aria-describedby={description ? descriptionId : undefined}
className={`mt-0.5 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed ${sizes.radio}`}
/>
<div>
<span className={`font-medium text-gray-700 ${sizes.label}`}>{label}</span>
{description && (
<p id={descriptionId} className={`text-gray-500 ${sizes.desc}`}>
{description}
</p>
)}
</div>
</label>
);
}
);Key aspects:
RadioGroup and RadioGroupItem share state via React context. This keeps the API clean: the parent holds value and onChange, while each item only declares its own value and label.default renders standard radios with labels, card wraps each option in a bordered card, and icon centers an icon with a hidden radio input. The variant is set once on the group and applies to all items.<fieldset disabled> attribute and context. Individual items can also be disabled independently.vertical uses space-y-3 for stacked layout, horizontal uses flex with wrapping. The orientation is set on the group container.aria-describedby connects error and helper text to the fieldset.All radios in a group must share the same name -- If even one radio has a different name, the browser treats it as a separate group and will not enforce mutual exclusivity. This is the most common bug with radio groups.
Uncontrolled radios cannot be unchecked -- Unlike checkboxes, a radio button cannot be unchecked by clicking it again. Once a selection is made in a radio group, the user can only change it, not clear it. Add a "None" option if clearing is needed.
Missing fieldset/legend hurts accessibility -- Screen readers use <fieldset> and <legend> to announce the group label before the first option. Without them, users hear individual radio labels with no context about what the group represents.
Hidden radio inputs need focus styles on the card -- When using sr-only to hide radios in card or icon variants, keyboard focus indicators disappear. Add has-[:focus-visible]:ring-2 on the card label (Tailwind v3.4+) or manage a focused state manually.
Arrow keys vs Tab for navigation -- Within a radio group, arrow keys move between options and Tab moves to the next focusable element outside the group. If only one radio is focusable (the selected one), Tab skips the entire group. This is correct browser behavior, not a bug.
defaultValue with controlled radio groups -- Using defaultChecked on individual radios while also passing a controlled value causes conflicting state. Always use controlled (checked) or uncontrolled (defaultChecked) consistently across all radios in the group.
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥