Interruptor
Un control de alternancia activado/desactivado que representa visualmente un estado booleano, comúnmente utilizado para configuraciones, preferencias e indicadores de características.
Busca en todas las páginas de la documentación
Un control de alternancia activado/desactivado que representa visualmente un estado booleano, comúnmente utilizado para configuraciones, preferencias e indicadores de características.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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:
name, se renderiza una entrada oculta para que el valor del interruptor se incluya en envíos de formularios nativos y FormData.focus-visible aparece solo para navegación por teclado, no para clics de ratón, dando una apariencia limpia mientras permanece accesible.Enter y Space garantizan un comportamiento consistente en navegadores, ya que algunos navegadores no disparan click en Space para controles no nativos.duration-200 tanto en la pista como en el pulgar crea una animación suave y coordinada sin parecer lenta.Falta role="switch" -- sin este rol, los lectores de pantalla tratan el elemento como un botón plano. El atributo aria-checked solo es válido cuando el rol es switch o checkbox.
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> con role="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 defecto type="submit", lo que enviará el formulario cuando se haga clic en el interruptor. Siempre añade type="button" para prevenir esto.
El manejador de clics se dispara en deshabilitado -- aunque el atributo nativo disabled previene eventos de clic, algunos patrones de delegación de eventos o manejadores onClick envolventes 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.
Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥