Grupo de Toggle
Un control segmentado donde una o más opciones pueden ser seleccionadas desde una fila de botones, comúnmente usado para cambiadores de vista, filtros y selectores de opciones compactos.
Casos de Uso
- Cambiar entre vistas de cuadrícula y lista en una visualización de datos
- Seleccionar un filtro de rango de tiempo (día, semana, mes, año)
- Elegir una opción de alineación de texto (izquierda, centro, derecha, justificar)
- Seleccionar un método de envío o nivel de plan
- Alternar opciones de formato (negrita, cursiva, subrayado) en una barra de herramientas de texto enriquecido
- Seleccionar un filtro de categoría o etiqueta en resultados de búsqueda
- Cambiar entre pestañas o modos en una barra de herramientas compacta
Implementación Más Simple
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}Un grupo de toggle de selección única mínimo usando role="radiogroup" y role="radio" para accesibilidad. El botón seleccionado obtiene un fondo azul mientras que los botones no seleccionados permanecen blancos. Las utilidades first:rounded-l-lg y last:rounded-r-lg redondean solo las esquinas externas, creando una forma de píldora conectada.
Variaciones
Selección Única con Opciones Tipadas
"use client";
interface ToggleOption {
value: string;
label: string;
}
interface ToggleGroupProps {
options: ToggleOption[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option.value}
role="radio"
aria-checked={value === option.value}
onClick={() => onChange(option.value)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option.value
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option.label}
</button>
))}
</div>
);
}Separar value de label permite que el texto mostrado difiera del valor programático. Esto es común cuando los valores son cadenas tipo enum ("grid", "list") pero las etiquetas necesitan ser más descriptivas o localizadas.
Selección Múltiple
"use client";
interface ToggleGroupProps {
options: string[];
values: string[];
onChange: (values: string[]) => void;
}
export function ToggleGroup({ options, values, onChange }: ToggleGroupProps) {
function toggle(option: string) {
if (values.includes(option)) {
onChange(values.filter((v) => v !== option));
} else {
onChange([...values, option]);
}
}
return (
<div className="inline-flex rounded-lg border border-gray-300" role="group" aria-label="Opciones de alternancia">
{options.map((option) => (
<button
key={option}
role="checkbox"
aria-checked={values.includes(option)}
onClick={() => toggle(option)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
values.includes(option)
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}La selección múltiple usa role="checkbox" en lugar de role="radio" porque múltiples opciones pueden estar activas simultáneamente. La función toggle agrega o elimina la opción seleccionada del array de valores. Este patrón es común para barras de herramientas de formato de texto (negrita, cursiva, subrayado).
Con Iconos
"use client";
interface ToggleOption {
value: string;
label: string;
icon: React.ReactNode;
}
interface ToggleGroupProps {
options: ToggleOption[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option.value}
role="radio"
aria-checked={value === option.value}
aria-label={option.label}
onClick={() => onChange(option.value)}
className={`inline-flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option.value
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option.icon}
<span className="hidden sm:inline">{option.label}</span>
</button>
))}
</div>
);
}
// Uso:
// <ToggleGroup
// value={view}
// onChange={setView}
// options={[
// {
// value: "grid",
// label: "Cuadrícula",
// icon: <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" /></svg>,
// },
// {
// value: "list",
// label: "Lista",
// icon: <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>,
// },
// ]}
// />Los iconos se muestran junto a las etiquetas, con las etiquetas ocultas en pantallas pequeñas mediante hidden sm:inline para ahorrar espacio. El aria-label asegura la accesibilidad cuando el texto de la etiqueta no es visible. Este es el patrón estándar para controles de cambiador de vista.
Estilo de Píldora
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex gap-1 rounded-full bg-gray-100 p-1" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-all ${
value === option
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
{option}
</button>
))}
</div>
);
}Un control segmentado en forma de píldora con una bandeja de fondo. La opción seleccionada obtiene un fondo blanco con una sombra sutil, creando un efecto de pestaña elevada. El gap-1 y p-1 en el contenedor proporcionan espaciado interno uniforme. Este estilo es popular en interfaces inspiradas en iOS y dashboards modernos.
Estilo de Contorno
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex gap-2" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`rounded-lg border-2 px-4 py-2 text-sm font-medium transition-colors ${
value === option
? "border-blue-600 bg-blue-50 text-blue-700"
: "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}Cada opción es un botón con borde independiente con espaciado entre ellos en lugar de una tira conectada. El botón seleccionado obtiene un borde azul y un fondo teñido. Este estilo funciona bien cuando las opciones son visualmente pesadas (por ejemplo, tarjetas con descripciones) y necesitan más separación.
Tamaños (sm / md / lg)
"use client";
type GroupSize = "sm" | "md" | "lg";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
size?: GroupSize;
}
const sizeClasses: Record<GroupSize, string> = {
sm: "px-2.5 py-1 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
export function ToggleGroup({ options, value, onChange, size = "md" }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${sizeClasses[size]} ${
value === option
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}Un mapa de tamaño controla el relleno y el tamaño de fuente juntos para mantener proporciones consistentes en cada tamaño. Pequeño funciona bien en barras de herramientas densas, mediano es el predeterminado para formularios, y grande se adapta a secciones hero o filtros prominentes.
Implementación Compleja
"use client";
import { forwardRef, useId, useCallback, useRef } from "react";
type SelectionMode = "single" | "multiple";
type GroupSize = "sm" | "md" | "lg";
type GroupVariant = "default" | "pill" | "outline";
interface ToggleOption {
value: string;
label: string;
icon?: React.ReactNode;
disabled?: boolean;
}
interface ToggleGroupBaseProps {
options: ToggleOption[];
size?: GroupSize;
variant?: GroupVariant;
disabled?: boolean;
label?: string;
className?: string;
}
interface SingleToggleGroupProps extends ToggleGroupBaseProps {
mode?: "single";
value: string;
onChange: (value: string) => void;
}
interface MultipleToggleGroupProps extends ToggleGroupBaseProps {
mode: "multiple";
value: string[];
onChange: (value: string[]) => void;
}
type ToggleGroupProps = SingleToggleGroupProps | MultipleToggleGroupProps;
const sizeClasses: Record<GroupSize, string> = {
sm: "px-2.5 py-1 text-xs gap-1.5",
md: "px-4 py-2 text-sm gap-2",
lg: "px-6 py-3 text-base gap-2.5",
};
const variantClasses: Record<GroupVariant, { container: string; active: string; inactive: string; rounded: string }> = {
default: {
container: "inline-flex rounded-lg border border-gray-300",
active: "bg-blue-600 text-white",
inactive: "bg-white text-gray-700 hover:bg-gray-50",
rounded: "first:rounded-l-lg last:rounded-r-lg",
},
pill: {
container: "inline-flex gap-1 rounded-full bg-gray-100 p-1",
active: "bg-white text-gray-900 shadow-sm",
inactive: "text-gray-600 hover:text-gray-900",
rounded: "rounded-full",
},
outline: {
container: "inline-flex gap-2",
active: "border-blue-600 bg-blue-50 text-blue-700",
inactive: "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50",
rounded: "rounded-lg border-2",
},
};
export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(function ToggleGroup(
props,
ref
) {
const {
options,
size = "md",
variant = "default",
disabled = false,
label,
className,
mode = "single",
value,
onChange,
} = props;
const groupId = useId();
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const v = variantClasses[variant];
const isSelected = useCallback(
(optionValue: string) => {
if (mode === "multiple") {
return (value as string[]).includes(optionValue);
}
return value === optionValue;
},
[mode, value]
);
const handleClick = useCallback(
(optionValue: string) => {
if (disabled) return;
if (mode === "multiple") {
const currentValues = value as string[];
const multiOnChange = onChange as (v: string[]) => void;
if (currentValues.includes(optionValue)) {
multiOnChange(currentValues.filter((v) => v !== optionValue));
} else {
multiOnChange([...currentValues, optionValue]);
}
} else {
(onChange as (v: string) => void)(optionValue);
}
},
[disabled, mode, value, onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, index: number) => {
const enabledIndices = options
.map((opt, i) => (!opt.disabled ? i : -1))
.filter((i) => i !== -1);
const currentEnabledIndex = enabledIndices.indexOf(index);
let nextIndex: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
nextIndex = enabledIndices[(currentEnabledIndex + 1) % enabledIndices.length];
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
nextIndex = enabledIndices[(currentEnabledIndex - 1 + enabledIndices.length) % enabledIndices.length];
break;
case "Home":
e.preventDefault();
nextIndex = enabledIndices[0];
break;
case "End":
e.preventDefault();
nextIndex = enabledIndices[enabledIndices.length - 1];
break;
}
if (nextIndex !== null) {
buttonRefs.current[nextIndex]?.focus();
if (mode === "single") {
handleClick(options[nextIndex].value);
}
}
},
[options, mode, handleClick]
);
const role = mode === "single" ? "radiogroup" : "group";
const itemRole = mode === "single" ? "radio" : "checkbox";
return (
<div className={className}>
{label && (
<span id={`${groupId}-label`} className="mb-2 block text-sm font-medium text-gray-700">
{label}
</span>
)}
<div
ref={ref}
role={role}
aria-labelledby={label ? `${groupId}-label` : undefined}
aria-label={label ? undefined : "Grupo de toggle"}
className={`${v.container} ${disabled ? "opacity-50" : ""}`}
>
{options.map((option, index) => {
const selected = isSelected(option.value);
const isDisabled = disabled || !!option.disabled;
return (
<button
key={option.value}
ref={(el) => { buttonRefs.current[index] = el; }}
role={itemRole}
type="button"
aria-checked={selected}
aria-label={option.icon && !option.label ? option.value : undefined}
disabled={isDisabled}
tabIndex={
mode === "single"
? selected || (value === "" && index === 0) ? 0 : -1
: 0
}
onClick={() => handleClick(option.value)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={[
"inline-flex items-center font-medium transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1",
sizeClasses[size],
v.rounded,
isDisabled ? "cursor-not-allowed" : "cursor-pointer",
selected ? v.active : v.inactive,
].join(" ")}
>
{option.icon && <span className="shrink-0">{option.icon}</span>}
{option.label && <span>{option.label}</span>}
</button>
);
})}
</div>
</div>
);
});Aspectos clave:
- Props de unión discriminada -- el prop
modecambia entreSingleToggleGroupPropsyMultipleToggleGroupProps, proporcionando tipos TypeScript correctos paravalueyonChangeen el sitio de llamada. El modo único usa una cadena, el modo múltiple usa un array de cadenas. - Tabindex itinerante -- en modo de selección única, solo el botón seleccionado tiene
tabIndex={0}. Las teclas de flecha mueven el enfoque y la selección juntos, coincidiendo con el patrón de grupo de radio WAI-ARIA. En modo de selección múltiple, todos los botones son tabulables. - Navegación de teclas de flecha -- el manejador de teclado admite teclas ArrowRight/Left, ArrowUp/Down, Home y End. Las opciones deshabilitadas se omiten en el ciclo de navegación. En modo único, el movimiento de enfoque también selecciona la opción.
- Sistema de variantes -- tres variantes visuales (default, pill, outline) se definen como mapas de clases, manteniendo la lógica de renderizado limpia. Cada variante especifica sus propias clases de contenedor, activo, inactivo y radio de borde.
- Deshabilitado por opción -- las opciones individuales pueden deshabilitarse independientemente de todo el grupo. Las opciones deshabilitadas se excluyen de la navegación por teclado y tienen estilo
cursor-not-allowed. - forwardRef en el contenedor del grupo -- permite que los componentes principales midan el ancho del grupo para diseños responsivos o desplacen el grupo a la vista.
- aria-labelledby -- cuando se proporciona un prop
labelvisible, el grupo lo referencia mediantearia-labelledbypara que los lectores de pantalla anuncien el propósito del grupo. Sin una etiqueta, se usa unaaria-labelgenérica como respaldo. - anillo focus-visible -- el anillo de enfoque aparece solo durante la navegación por teclado, manteniendo la interfaz de usuario limpia para usuarios de ratón mientras se mantiene la accesibilidad.
Problemas Comunes
-
Usar
role="radiogroup"para selección múltiple -- los grupos de radio solo permiten una selección. Si múltiples opciones pueden estar activas, usarole="group"conrole="checkbox"en cada botón en lugar derole="radio". -
Falta de
type="button"dentro de formularios -- los botones dentro de un<form>usan por defectotype="submit". Cada botón de toggle necesitatype="button"para evitar el envío del formulario al hacer clic. -
Tailwind
first:ylast:no se aplican -- si los botones están envueltos en elementos adicionales (como un<div>para tooltips), las pseudo-clasesfirst:ylast:apuntan a los envoltorios, no a los botones. Aplica border-radius al envoltorio en su lugar. -
Navegación por teclado sin tabindex itinerante -- si cada botón tiene
tabIndex={0}, el usuario debe presionar Tab a través de cada opción para salir del grupo. Usa tabindex itinerante (tabIndex={-1}en elementos no seleccionados) para que Tab se mueva más allá de todo el grupo en un pulsación de tecla. -
Igualdad de referencia de array en selección múltiple -- pasar una nueva referencia de array en cada renderizado (por ejemplo,
values={[...selected]}) puede causar re-renderizados innecesarios en componentes hijo. Memoriza el array de valores o usa una referencia estable. -
Estado seleccionado perdido al remontar -- si el grupo de toggle se renderiza condicionalmente (por ejemplo, dentro de un panel de pestaña), la selección se restablece a menos que el estado se eleve a un padre o se persista. Siempre almacena el valor en el antepasado más cercano estable.
-
Brecha inconsistente entre botones conectados -- los bordes en botones adyacentes se duplican, creando una línea de 2px entre ellos. Usa
border-l-0en todos excepto el primer botón, o usa un borde único en el contenedor con la utilidaddivide-x.
Relacionado
- Botón -- Componente de botón individual usado como elemento base
- Pestañas -- Patrón de navegación que usa un diseño segmentado similar
- Interruptor -- Toggle binario para una única opción activada/desactivada
- Formularios -- Patrones de formularios para gestionar el estado de selección de grupo