Switch
An on/off toggle control that visually represents a boolean state, commonly used for settings, preferences, and feature flags.
Use Cases
- Enable or disable application settings (dark mode, notifications)
- Toggle feature flags in admin panels
- Opt-in/opt-out controls for marketing emails or cookies
- Show/hide sections of a form dynamically
- Mute/unmute audio or video in a media player
- Activate/deactivate integrations in a dashboard
- Toggle between two mutually exclusive modes (public/private)
Simplest Implementation
"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.
Variations
With Label
"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.
With Description
"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.
Sizes (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>
);
}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.
Colored Variants
"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.
Disabled State
"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.
Complex Implementation
"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:
- forwardRef -- allows parent components to attach a ref for focus management or integration with form libraries that need imperative access.
- Hidden input for form serialization -- when a
nameprop is provided, a hidden input is rendered so the switch value is included in native form submissions andFormData. - focus-visible ring -- the
focus-visiblering appears only for keyboard navigation, not mouse clicks, giving a clean look while remaining accessible. - aria-checked and role="switch" -- the correct ARIA role ensures screen readers announce the control as a toggle with its current on/off state rather than a generic button.
- aria-describedby -- links the description text to the switch so screen readers announce supplementary context when the control receives focus.
- Keyboard handling -- explicit
EnterandSpacekey handlers ensure consistent behavior across browsers, since some browsers do not fireclickon Space for non-native controls. - Transition duration --
duration-200on both the track and thumb creates a smooth, coordinated animation without feeling sluggish. - shrink-0 -- prevents the switch from collapsing when placed inside a flex container with long label text.
Gotchas
-
Missing
role="switch"-- without this role, screen readers treat the element as a plain button. Thearia-checkedattribute is only valid when the role isswitchorcheckbox. -
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>withrole="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 totype="submit", which will submit the form when the switch is clicked. Always addtype="button"to prevent this. -
Click handler fires on disabled -- although the native
disabledattribute prevents click events, some event delegation patterns or wrapperonClickhandlers 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.
Related
- Input -- Text inputs used alongside switches in forms
- Forms -- Form patterns and controlled component strategies
- Toggle Group -- For selecting from multiple options instead of binary on/off