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.
Busca en todas las páginas de la documentación
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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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).
"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.
"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.
"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.
"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.
"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:
mode cambia entre SingleToggleGroupProps y MultipleToggleGroupProps, proporcionando tipos TypeScript correctos para value y onChange en el sitio de llamada. El modo único usa una cadena, el modo múltiple usa un array de cadenas.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.cursor-not-allowed.label visible, el grupo lo referencia mediante aria-labelledby para que los lectores de pantalla anuncien el propósito del grupo. Sin una etiqueta, se usa una aria-label genérica como respaldo.Usar role="radiogroup" para selección múltiple -- los grupos de radio solo permiten una selección. Si múltiples opciones pueden estar activas, usa role="group" con role="checkbox" en cada botón en lugar de role="radio".
Falta de type="button" dentro de formularios -- los botones dentro de un <form> usan por defecto type="submit". Cada botón de toggle necesita type="button" para evitar el envío del formulario al hacer clic.
Tailwind first: y last: no se aplican -- si los botones están envueltos en elementos adicionales (como un <div> para tooltips), las pseudo-clases first: y last: 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-0 en todos excepto el primer botón, o usa un borde único en el contenedor con la utilidad divide-x.
Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥