Interruptor
Un control de alternancia activado/desactivado que representa visualmente un estado booleano, comúnmente utilizado para configuraciones, preferencias e indicadores de características.
Casos de uso
- Habilitar o deshabilitar configuraciones de la aplicación (modo oscuro, notificaciones)
- Alternar indicadores de características en paneles de administración
- Controles de opción de inclusión/exclusión para correos electrónicos de marketing o cookies
- Mostrar/ocultar secciones de un formulario de forma dinámica
- Silenciar/activar sonido en audio o vídeo en un reproductor multimedia
- Activar/desactivar integraciones en un panel de control
- Alternar entre dos modos mutuamente excluyentes (público/privado)
Implementación más simple
"use client";
import { useState } from "react";
interface SwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
}
export function Switch({ checked, onChange }: SwitchProps) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
checked ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
checked ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
);
}Un interruptor mínimo construido sobre un <button> con role="switch" y aria-checked para accesibilidad. El pulgar se desliza entre posiciones usando translate-x y el color de la pista realiza la transición entre gris y azul. No se necesita una casilla de verificación oculta porque el botón mismo actúa como el control del formulario.
Variaciones
Con etiqueta
"use client";
import { useId } from "react";
interface SwitchProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function Switch({ label, checked, onChange }: SwitchProps) {
const id = useId();
return (
<div className="flex items-center gap-3">
<button
id={id}
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
checked ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
checked ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<label htmlFor={id} className="text-sm font-medium text-gray-700 cursor-pointer">
{label}
</label>
</div>
);
}La etiqueta está conectada al interruptor mediante htmlFor y el id generado, por lo que al hacer clic en el texto de la etiqueta también se alterna el interruptor. La clase cursor-pointer señala que la etiqueta es interactiva.
Con descripción
"use client";
import { useId } from "react";
interface SwitchProps {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function Switch({ label, description, checked, onChange }: SwitchProps) {
const id = useId();
const descId = `${id}-desc`;
return (
<div className="flex items-start justify-between gap-4">
<div>
<label htmlFor={id} className="text-sm font-medium text-gray-900 cursor-pointer">
{label}
</label>
<p id={descId} className="text-sm text-gray-500">
{description}
</p>
</div>
<button
id={id}
role="switch"
aria-checked={checked}
aria-describedby={descId}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
checked ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
checked ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
);
}Un diseño de estilo de configuración con la etiqueta y descripción a la izquierda, interruptor a la derecha. El atributo aria-describedby vincula la descripción al interruptor para que los lectores de pantalla lo anuncien. La clase shrink-0 evita que el interruptor se comprima por texto largo.
Tamaños (sm / md / lg)
"use client";
type SwitchSize = "sm" | "md" | "lg";
interface SwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
size?: SwitchSize;
}
const sizeClasses: Record<SwitchSize, { track: string; thumb: string; translate: string }> = {
sm: { track: "h-5 w-9", thumb: "h-3 w-3", translate: "translate-x-5" },
md: { track: "h-6 w-11", thumb: "h-4 w-4", translate: "translate-x-6" },
lg: { track: "h-8 w-14", thumb: "h-6 w-6", translate: "translate-x-7" },
};
export function Switch({ checked, onChange, size = "md" }: SwitchProps) {
const s = sizeClasses[size];
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex items-center rounded-full transition-colors ${s.track} ${
checked ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block rounded-full bg-white transition-transform ${s.thumb} ${
checked ? s.translate : "translate-x-1"
}`}
/>
</button>
);
}Un mapa de tamaños mantiene la pista y el pulgar proporcionales en cada punto de interrupción. La distancia de traducción se ajusta por tamaño para que el pulgar se coloque correctamente contra el borde de la pista en ambos estados.
Variantes de color
"use client";
type SwitchColor = "blue" | "green" | "red";
interface SwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
color?: SwitchColor;
}
const colorClasses: Record<SwitchColor, string> = {
blue: "bg-blue-600",
green: "bg-green-600",
red: "bg-red-600",
};
export function Switch({ checked, onChange, color = "blue" }: SwitchProps) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
checked ? colorClasses[color] : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
checked ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
);
}Diferentes colores comunican intención: verde para éxito/habilitar, rojo para destructivo/peligroso, azul para configuraciones neutrales. El estado desactivado permanece gris en todas las variantes para consistencia.
Estado deshabilitado
"use client";
interface SwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
}
export function Switch({ checked, onChange, disabled = false, label }: SwitchProps) {
return (
<div className="flex items-center gap-3">
<button
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
disabled
? "cursor-not-allowed opacity-50"
: ""
} ${checked ? "bg-blue-600" : "bg-gray-300"}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
checked ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
{label && (
<span className={`text-sm font-medium ${disabled ? "text-gray-400" : "text-gray-700"}`}>
{label}
</span>
)}
</div>
);
}El atributo disabled en el botón previene clics de forma nativa. Las clases opacity-50 y cursor-not-allowed dan una indicación visual clara de que el control está inactivo. El texto de la etiqueta también se oscurece para reforzar el estado deshabilitado.
Implementación compleja
"use client";
import { forwardRef, useId, useCallback } from "react";
type SwitchSize = "sm" | "md" | "lg";
type SwitchColor = "blue" | "green" | "red";
interface SwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
label?: string;
description?: string;
size?: SwitchSize;
color?: SwitchColor;
disabled?: boolean;
name?: string;
id?: string;
className?: string;
}
const sizeClasses: Record<SwitchSize, { track: string; thumb: string; translate: string }> = {
sm: { track: "h-5 w-9", thumb: "h-3 w-3", translate: "translate-x-5" },
md: { track: "h-6 w-11", thumb: "h-4 w-4", translate: "translate-x-6" },
lg: { track: "h-8 w-14", thumb: "h-6 w-6", translate: "translate-x-7" },
};
const colorClasses: Record<SwitchColor, string> = {
blue: "bg-blue-600",
green: "bg-green-600",
red: "bg-red-600",
};
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(function Switch(
{
checked,
onChange,
label,
description,
size = "md",
color = "blue",
disabled = false,
name,
id: externalId,
className,
},
ref
) {
const generatedId = useId();
const switchId = externalId ?? generatedId;
const descId = `${switchId}-desc`;
const s = sizeClasses[size];
const handleClick = useCallback(() => {
if (!disabled) onChange(!checked);
}, [disabled, checked, onChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onChange(!checked);
}
},
[disabled, checked, onChange]
);
return (
<div className={`flex items-start justify-between gap-4 ${className ?? ""}`}>
{(label || description) && (
<div className="min-w-0">
{label && (
<label
htmlFor={switchId}
className={`block text-sm font-medium cursor-pointer ${
disabled ? "text-gray-400" : "text-gray-900"
}`}
>
{label}
</label>
)}
{description && (
<p
id={descId}
className={`text-sm ${disabled ? "text-gray-300" : "text-gray-500"}`}
>
{description}
</p>
)}
</div>
)}
{/* Entrada oculta para serialización de formularios */}
{name && (
<input type="hidden" name={name} value={checked ? "on" : "off"} />
)}
<button
ref={ref}
id={switchId}
role="switch"
type="button"
aria-checked={checked}
aria-describedby={description ? descId : undefined}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={[
"relative inline-flex shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
s.track,
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
checked ? colorClasses[color] : "bg-gray-300",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"inline-block rounded-full bg-white shadow-sm transition-transform duration-200",
s.thumb,
checked ? s.translate : "translate-x-1",
].join(" ")}
/>
</button>
</div>
);
});Aspectos clave:
- forwardRef -- permite que los componentes padre adjunten un ref para gestión del enfoque o integración con bibliotecas de formularios que necesitan acceso imperativo.
- Entrada oculta para serialización de formularios -- cuando se proporciona un prop
name, se renderiza una entrada oculta para que el valor del interruptor se incluya en envíos de formularios nativos yFormData. - anillo focus-visible -- el anillo
focus-visibleaparece solo para navegación por teclado, no para clics de ratón, dando una apariencia limpia mientras permanece accesible. - aria-checked y role="switch" -- el rol ARIA correcto garantiza que los lectores de pantalla anuncien el control como un interruptor con su estado actual activado/desactivado en lugar de un botón genérico.
- aria-describedby -- vincula el texto de descripción al interruptor para que los lectores de pantalla anuncien el contexto complementario cuando el control recibe enfoque.
- Manejo de teclado -- los manejadores explícitos de tecla
EnterySpacegarantizan un comportamiento consistente en navegadores, ya que algunos navegadores no disparanclicken Space para controles no nativos. - Duración de transición --
duration-200tanto en la pista como en el pulgar crea una animación suave y coordinada sin parecer lenta. - shrink-0 -- evita que el interruptor se colapse cuando se coloca dentro de un contenedor flex con texto de etiqueta largo.
Problemas comunes
-
Falta
role="switch"-- sin este rol, los lectores de pantalla tratan el elemento como un botón plano. El atributoaria-checkedsolo es válido cuando el rol esswitchocheckbox. -
Usar una casilla de verificación en lugar de un botón -- una casilla de verificación oculta con una superposición visual funciona pero requiere un manejo de teclado cuidadoso y asociación de etiquetas. Un
<button>conrole="switch"es más simple y predecible. -
La transición no se anima -- si alternas clases que Tailwind elimina (p. ej.,
translate-x-6), la clase no existirá en producción. Asegúrate de que todos los valores translate aparezcan en tu configuración de Tailwind safelist o siempre se referencien en el origen. -
La posición del pulgar desactivada por un píxel -- la distancia de traducción del pulgar debe tener en cuenta el relleno de la pista. Si el pulgar no se sienta correctamente contra el borde de la pista, ajusta el valor de translate o añade relleno a la pista.
-
No usar
type="button"-- dentro de un formulario, un<button>tiene por defectotype="submit", lo que enviará el formulario cuando se haga clic en el interruptor. Siempre añadetype="button"para prevenir esto. -
El manejador de clics se dispara en deshabilitado -- aunque el atributo nativo
disabledpreviene eventos de clic, algunos patrones de delegación de eventos o manejadoresonClickenvolventes pueden dispararse. Siempre protege con un retorno temprano en el manejador. -
Sin serialización de valor del formulario -- a diferencia de una casilla de verificación nativa, un interruptor basado en botón no aparece automáticamente en
FormData. Incluye una entrada oculta con el estado del interruptor para admitir envíos de formularios nativos.
Relacionado
- Input -- Entradas de texto utilizadas junto con interruptores en formularios
- Formularios -- Patrones de formularios y estrategias de componentes controlados
- Toggle Group -- Para seleccionar entre múltiples opciones en lugar de alternancia binaria activada/desactivada