Grupo de Radio
Un conjunto de botones de radio mutuamente excluyentes para seleccionar una opción de un grupo. Utiliza inputs de radio nativos con atributos name compartidos para navegación de teclado integrada y accesibilidad.
Casos de Uso
- Selección de método de pago (tarjeta de crédito, PayPal, transferencia bancaria)
- Opciones de velocidad de envío en el checkout
- Selección de plan o nivel en páginas de precios
- Selección de género o datos demográficos en formularios de perfil
- Selección del orden de clasificación (más nuevos, más antiguos, populares)
- Preferencia de tema (claro, oscuro, sistema)
- Preguntas de opción única en encuestas o sondeos
Implementación Más Simple
"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>
);
}Un grupo de radio mínimo usando <fieldset> y <legend> para agrupación semántica. Todos los radios comparten el mismo atributo name, lo que permite al navegador garantizar exclusividad mutua y habilita navegación con teclas de flecha entre opciones de forma nativa.
Variaciones
Vertical Básico
"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>
);
}Una pila vertical de opciones de radio con estado de error opcional. El atributo aria-invalid en cada input de radio comunica problemas de validación a lectores de pantalla. El mensaje de error aparece debajo del grupo completo.
Diseño Horizontal
"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>
);
}Las opciones se organizan en una fila horizontal usando flex y gap-4. La clase flex-wrap asegura que las opciones se envuelvan en la siguiente línea en pantallas estrechas en lugar de desbordarse. Mejor para grupos con 2-4 opciones con etiquetas cortas.
Con Descripciones
"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>
);
}Cada opción tiene una descripción secundaria debajo de la etiqueta. El input de radio está alineado en la parte superior del bloque de texto con items-start y mt-0.5. Este patrón funciona bien para selección de planes u opciones de envío donde cada opción necesita explicación.
Radio Estilo Tarjeta
"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>
);
}Envuelve cada opción de radio en una tarjeta con un borde visible que se destaca cuando se selecciona. La tarjeta seleccionada obtiene un borde azul y fondo azul claro. Un campo price opcional está alineado a la derecha en la fila del encabezado. Este patrón es común para niveles de precios o selección de método de envío.
Con Iconos
"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>
);
}Una cuadrícula de tarjetas de radio centradas en iconos con el input de radio nativo visualmente oculto usando sr-only. La tarjeta completa actúa como destino de clic. La tarjeta seleccionada se destaca con un borde azul y color de icono. Esto funciona bien para toggles de modo de vista (cuadrícula/lista), selección de tema o selectores de categoría.
Opciones Deshabilitadas
"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>
);
}Las opciones individuales se pueden deshabilitar mientras otras permanecen interactivas. La clase opacity-50 y cursor-not-allowed en la etiqueta proporcionan retroalimentación visual clara. Los botones de radio deshabilitados se omiten automáticamente durante la navegación de teclado por el navegador.
Implementación Compleja
"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 debe usarse dentro de 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>
);
}
);Aspectos clave:
- Patrón de componente compuesto --
RadioGroupyRadioGroupItemcomparten estado mediante React context. Esto mantiene la API limpia: el padre mantienevalueyonChange, mientras que cada elemento solo declara su propiovalueylabel. - forwardRef en elementos -- permite a los componentes padres adjuntar refs a inputs de radio individuales para gestión del enfoque o control programático.
- Tres variantes visuales --
defaultrenderiza radios estándar con etiquetas,cardenvuelve cada opción en una tarjeta con borde, eiconcentra un icono con un input de radio oculto. La variante se establece una vez en el grupo y se aplica a todos los elementos. - Deshabilitación en cascada -- deshabilitar el grupo desactiva todos los elementos tanto mediante el atributo
<fieldset disabled>como mediante context. Los elementos individuales también se pueden deshabilitar independientemente. - Soporte de orientación --
verticalusaspace-y-3para diseño apilado,horizontalusaflexcon envolvimiento. La orientación se establece en el contenedor del grupo. - useId para accesibilidad -- cada elemento genera su propio ID estable para vincular etiquetas y descripciones. El
aria-describedbya nivel de grupo conecta el texto de error y ayuda al fieldset. - aria-describedby en elementos -- los elementos individuales enlazan con su texto de descripción para que los lectores de pantalla anuncien la información secundaria cuando el radio recibe el enfoque.
Trampas Comunes
-
Todos los radios en un grupo deben compartir el mismo nombre -- Si aunque sea un radio tiene un
namediferente, el navegador lo trata como un grupo separado y no garantiza la exclusividad mutua. Este es el error más común con grupos de radio. -
Los radios no controlados no se pueden desmarcar -- A diferencia de las casillas de verificación, un botón de radio no se puede desmarcar haciendo clic en él nuevamente. Una vez que se realiza una selección en un grupo de radio, el usuario solo puede cambiarla, no borrarla. Añade una opción "None" si se necesita limpiar.
-
Falta de fieldset/legend perjudica la accesibilidad -- Los lectores de pantalla usan
<fieldset>y<legend>para anunciar la etiqueta del grupo antes de la primera opción. Sin ellos, los usuarios escuchan etiquetas de radio individuales sin contexto sobre qué representa el grupo. -
Los inputs de radio ocultos necesitan estilos de enfoque en la tarjeta -- Al usar
sr-onlypara ocultar radios en variantes de tarjeta o icono, los indicadores de enfoque de teclado desaparecen. Añadehas-[:focus-visible]:ring-2en la etiqueta de tarjeta (Tailwind v3.4+) o gestiona un estado enfocado manualmente. -
Teclas de flecha vs Tab para navegación -- Dentro de un grupo de radio, las teclas de flecha se mueven entre opciones y Tab se mueve al siguiente elemento enfocable fuera del grupo. Si solo un radio es enfocable (el seleccionado), Tab omite todo el grupo. Este es el comportamiento correcto del navegador, no un error.
-
defaultValue con grupos de radio controlados -- Usar
defaultCheckeden radios individuales mientras también se pasa unvaluecontrolado causa un estado conflictivo. Siempre usa controlado (checked) o no controlado (defaultChecked) consistentemente en todos los radios del grupo.
Relacionado
- Checkbox -- Alternancia booleana para escenarios de selección múltiple
- Select -- Alternativa desplegable para listas largas de opciones
- Card -- Componente de tarjeta usado como base para opciones de radio estilo tarjeta
- Forms -- Patrones de formularios y estrategias de validación
- Event Handling -- onChange y escritura de eventos