Switch
An on/off toggle control that visually represents a boolean state, commonly used for settings, preferences, and feature flags.
Search across all documentation pages
An on/off toggle control that visually represents a boolean state, commonly used for settings, preferences, and feature flags.
🤖 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>
);
}A minimal toggle built on a <button> with role="switch" and aria-checked for accessibility. The thumb slides between positions using translate-x and the track color transitions between gray and blue. No hidden checkbox is needed because the button itself acts as the form control.
"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>
);
}The label is connected to the switch via htmlFor and the generated id, so clicking the label text also toggles the switch. The cursor-pointer class signals that the label is interactive.
"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>
);
}A settings-style layout with the label and description on the left, switch on the right. The aria-describedby links the description to the switch so screen readers announce it. The shrink-0 class prevents the switch from being squished by long text.
"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>
);
}A size map keeps the track and thumb proportional at each breakpoint. The translate distance adjusts per size so the thumb lands flush against the track edge in both states.
"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>
);
}Different colors communicate intent -- green for success/enable, red for destructive/danger, blue for neutral settings. The off state remains gray across all variants for consistency.
"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>
);
}The disabled attribute on the button prevents clicks natively. The opacity-50 and cursor-not-allowed classes give a clear visual cue that the control is inactive. The label text also dims to reinforce the disabled state.
"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>
)}
{/* Hidden input for form serialization */}
{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>
);
});Key aspects:
name prop is provided, a hidden input is rendered so the switch value is included in native form submissions and FormData.focus-visible ring appears only for keyboard navigation, not mouse clicks, giving a clean look while remaining accessible.Enter and Space key handlers ensure consistent behavior across browsers, since some browsers do not fire click on Space for non-native controls.duration-200 on both the track and thumb creates a smooth, coordinated animation without feeling sluggish.Missing role="switch" -- without this role, screen readers treat the element as a plain button. The aria-checked attribute is only valid when the role is switch or checkbox.
Using a checkbox instead of a button -- a hidden checkbox with a visual overlay works but requires careful keyboard handling and label association. A <button> with role="switch" is simpler and more predictable.
Transition not animating -- if you toggle classes that Tailwind purges (e.g., translate-x-6), the class will not exist in production. Ensure all translate values appear in your Tailwind config safelist or are always referenced in the source.
Thumb position off by a pixel -- the thumb translate distance must account for the track padding. If the thumb does not sit flush against the track edge, adjust the translate value or add padding to the track.
Not using type="button" -- inside a form, a <button> defaults to type="submit", which will submit the form when the switch is clicked. Always add type="button" to prevent this.
Click handler fires on disabled -- although the native disabled attribute prevents click events, some event delegation patterns or wrapper onClick handlers may still fire. Always guard with an early return in the handler.
No form value serialization -- unlike a native checkbox, a button-based switch does not automatically appear in FormData. Include a hidden input with the switch state to support native form submissions.
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥