Switch Basic Toggle
Original · freebasic on/off switches in sizes
byWeb InnoventixReact + Tailwind
tglswitchbasictoggles
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/tgl-switch-basic.jsontgl-switch-basic.tsx
"use client";
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type SwitchSize = "sm" | "md" | "lg";
type Accent = "indigo" | "emerald" | "violet" | "rose" | "amber" | "sky";
type SwitchProps = {
checked: boolean;
onCheckedChange: (value: boolean) => void;
size?: SwitchSize;
accent?: Accent;
disabled?: boolean;
id?: string;
label?: string;
labelledBy?: string;
describedBy?: string;
};
const SIZE_MAP: Record<
SwitchSize,
{ track: string; thumb: string; icon: string; onX: number }
> = {
sm: { track: "h-5 w-9", thumb: "h-4 w-4", icon: "h-0 w-0", onX: 16 },
md: { track: "h-6 w-11", thumb: "h-5 w-5", icon: "h-2.5 w-2.5", onX: 20 },
lg: { track: "h-8 w-14", thumb: "h-7 w-7", icon: "h-3.5 w-3.5", onX: 24 },
};
const TRACK_ON: Record<Accent, string> = {
indigo: "bg-indigo-600 dark:bg-indigo-500",
emerald: "bg-emerald-600 dark:bg-emerald-500",
violet: "bg-violet-600 dark:bg-violet-500",
rose: "bg-rose-600 dark:bg-rose-500",
amber: "bg-amber-500 dark:bg-amber-400",
sky: "bg-sky-600 dark:bg-sky-500",
};
const RING: Record<Accent, string> = {
indigo: "focus-visible:ring-indigo-500",
emerald: "focus-visible:ring-emerald-500",
violet: "focus-visible:ring-violet-500",
rose: "focus-visible:ring-rose-500",
amber: "focus-visible:ring-amber-500",
sky: "focus-visible:ring-sky-500",
};
const ICON_COLOR: Record<Accent, string> = {
indigo: "text-indigo-600",
emerald: "text-emerald-600",
violet: "text-violet-600",
rose: "text-rose-600",
amber: "text-amber-500",
sky: "text-sky-600",
};
function CheckIcon({ className }: { className: string }) {
return (
<svg
viewBox="0 0 12 12"
fill="none"
className={className}
aria-hidden="true"
>
<path
d="M2.5 6.3 4.7 8.5 9.5 3.5"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function Switch({
checked,
onCheckedChange,
size = "md",
accent = "indigo",
disabled = false,
id,
label,
labelledBy,
describedBy,
}: SwitchProps) {
const reduce = useReducedMotion();
const dims = SIZE_MAP[size];
return (
<button
type="button"
role="switch"
id={id}
aria-checked={checked}
aria-label={labelledBy ? undefined : label}
aria-labelledby={labelledBy}
aria-describedby={describedBy}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
className={[
"relative inline-flex shrink-0 items-center rounded-full p-0.5",
"cursor-pointer outline-none transition-colors duration-200",
"ring-offset-white focus-visible:ring-2 focus-visible:ring-offset-2 dark:ring-offset-slate-950",
"disabled:cursor-not-allowed disabled:opacity-50",
dims.track,
RING[accent],
checked ? TRACK_ON[accent] : "bg-slate-200 dark:bg-slate-700",
].join(" ")}
>
<motion.span
className={[
"flex items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-black/5",
dims.thumb,
].join(" ")}
animate={{ x: checked ? dims.onX : 0 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 34, mass: 0.7 }
}
>
{checked && size !== "sm" ? (
<CheckIcon className={[dims.icon, ICON_COLOR[accent]].join(" ")} />
) : null}
</motion.span>
</button>
);
}
const SIZES: { key: SwitchSize; name: string; hint: string }[] = [
{ key: "sm", name: "Small", hint: "36 × 20" },
{ key: "md", name: "Medium", hint: "44 × 24" },
{ key: "lg", name: "Large", hint: "56 × 32" },
];
const ACCENTS: { key: Accent; name: string }[] = [
{ key: "indigo", name: "Indigo" },
{ key: "emerald", name: "Emerald" },
{ key: "violet", name: "Violet" },
{ key: "rose", name: "Rose" },
{ key: "amber", name: "Amber" },
{ key: "sky", name: "Sky" },
];
const SETTINGS: {
key: "notifications" | "twoFactor" | "autoUpdate" | "telemetry";
accent: Accent;
title: string;
desc: string;
}[] = [
{
key: "notifications",
accent: "indigo",
title: "Push notifications",
desc: "Get alerts on your devices when something needs your attention.",
},
{
key: "twoFactor",
accent: "emerald",
title: "Two-factor authentication",
desc: "Require a one-time verification code at each new sign-in.",
},
{
key: "autoUpdate",
accent: "sky",
title: "Automatic updates",
desc: "Install security patches as soon as they ship, no restart needed.",
},
{
key: "telemetry",
accent: "violet",
title: "Usage analytics",
desc: "Share anonymous product usage to help us improve the app.",
},
];
export default function TglSwitchBasic() {
const uid = useId();
const reduce = useReducedMotion();
const [sizes, setSizes] = useState<Record<SwitchSize, boolean>>({
sm: true,
md: true,
lg: false,
});
const [accents, setAccents] = useState<Record<Accent, boolean>>({
indigo: true,
emerald: true,
violet: false,
rose: true,
amber: false,
sky: true,
});
const [settings, setSettings] = useState({
notifications: true,
twoFactor: false,
autoUpdate: true,
telemetry: false,
});
const enabledCount = Object.values(settings).filter(Boolean).length;
const card =
"tglsw-rise rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900";
return (
<section className="relative w-full bg-slate-50 px-6 py-20 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes tglsw-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.tglsw-rise { animation: tglsw-rise .55s cubic-bezier(.22,.61,.36,1) both; }
@media (prefers-reduced-motion: reduce) {
.tglsw-rise { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="tglsw-rise max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-widest text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Toggles
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
On / off switches
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-500 dark:text-slate-400">
Accessible switch controls built on a real{" "}
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-[0.8em] text-slate-700 dark:bg-slate-800 dark:text-slate-300">
role="switch"
</code>{" "}
button. Fully keyboard driven, focus-visible rings, and three sizes.
</p>
</header>
<div className="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Sizes */}
<div className={card}>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Sizes
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
The same control at three scales. Tab to focus, then Space or
Enter to toggle.
</p>
<div className="mt-6 space-y-4">
{SIZES.map(({ key, name, hint }) => (
<div
key={key}
className="flex items-center justify-between gap-4 rounded-xl border border-slate-100 bg-slate-50/60 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/30"
>
<div>
<label
id={`${uid}-size-${key}-label`}
htmlFor={`${uid}-size-${key}`}
className="cursor-pointer text-sm font-medium text-slate-800 dark:text-slate-200"
>
{name}
</label>
<p className="text-xs tabular-nums text-slate-400 dark:text-slate-500">
{hint} px
</p>
</div>
<Switch
id={`${uid}-size-${key}`}
labelledBy={`${uid}-size-${key}-label`}
size={key}
accent="indigo"
checked={sizes[key]}
onCheckedChange={(v) =>
setSizes((s) => ({ ...s, [key]: v }))
}
/>
</div>
))}
</div>
</div>
{/* Accents */}
<div className={card}>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Accent colors
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Six portable palettes. Each keeps its own focus ring color.
</p>
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
{ACCENTS.map(({ key, name }) => (
<label
key={key}
htmlFor={`${uid}-accent-${key}`}
className="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50/60 px-3 py-3 dark:border-slate-800 dark:bg-slate-800/30"
>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{name}
</span>
<Switch
id={`${uid}-accent-${key}`}
label={`${name} accent`}
size="sm"
accent={key}
checked={accents[key]}
onCheckedChange={(v) =>
setAccents((a) => ({ ...a, [key]: v }))
}
/>
</label>
))}
</div>
</div>
{/* Settings list */}
<div className={`${card} lg:col-span-2`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Account settings
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Labels and descriptions wired with{" "}
<code className="rounded bg-slate-100 px-1 py-0.5 text-[0.8em] text-slate-700 dark:bg-slate-800 dark:text-slate-300">
aria-labelledby
</code>
.
</p>
</div>
<span
aria-live="polite"
className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 tabular-nums dark:bg-emerald-500/10 dark:text-emerald-300"
>
{enabledCount} of {SETTINGS.length} enabled
</span>
</div>
<div className="mt-5 divide-y divide-slate-100 dark:divide-slate-800">
{SETTINGS.map(({ key, accent, title, desc }) => (
<div
key={key}
className="flex items-center justify-between gap-6 py-4"
>
<div className="min-w-0">
<label
id={`${uid}-set-${key}-label`}
htmlFor={`${uid}-set-${key}`}
className="cursor-pointer text-sm font-medium text-slate-800 dark:text-slate-200"
>
{title}
</label>
<p
id={`${uid}-set-${key}-desc`}
className="mt-0.5 text-sm text-slate-500 dark:text-slate-400"
>
{desc}
</p>
</div>
<Switch
id={`${uid}-set-${key}`}
labelledBy={`${uid}-set-${key}-label`}
describedBy={`${uid}-set-${key}-desc`}
size="md"
accent={accent}
checked={settings[key]}
onCheckedChange={(v) =>
setSettings((s) => ({ ...s, [key]: v }))
}
/>
</div>
))}
</div>
</div>
{/* Disabled states */}
<div className={`${card} lg:col-span-2`}>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Disabled
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Non-interactive states retain their on/off appearance and skip
focus.
</p>
<div className="mt-6 flex flex-wrap items-center gap-8">
<div className="flex items-center gap-3">
<Switch
label="Disabled, off"
size="md"
accent="indigo"
disabled
checked={false}
onCheckedChange={() => {}}
/>
<span className="text-sm text-slate-500 dark:text-slate-400">
Disabled · off
</span>
</div>
<div className="flex items-center gap-3">
<Switch
label="Disabled, on"
size="md"
accent="emerald"
disabled
checked={true}
onCheckedChange={() => {}}
/>
<span className="text-sm text-slate-500 dark:text-slate-400">
Disabled · on
</span>
</div>
</div>
</div>
</div>
<p className="tglsw-rise mt-8 text-center text-xs text-slate-400 dark:text-slate-500">
{reduce
? "Reduced-motion preference detected — thumb transitions are disabled."
: "Thumb glides on a spring; respects prefers-reduced-motion."}
</p>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Checkbox Set Toggle
Originaldefault/checked/indeterminate/disabled checkboxes

Checkbox Custom Toggle
Originalcustom animated tick checkboxes

Checkbox Card Toggle
Originalselectable card checkboxes

Checkbox Indeterminate Toggle
Originalparent/child indeterminate checkboxes

Checkbox List Toggle
Originalcheckbox list with select-all

Radio Set Toggle
Originalstyled radio button group

Radio Cards Toggle
Originalselectable radio cards

Radio Segmented Toggle
Originalsegmented control radios

Switch iOS Toggle
OriginaliOS-style switches
Switch Icons Toggle
Originalswitches with sun/moon style icons

Switch Labels Toggle
Originalswitches with on/off labels inside

Switch Group Toggle
Originala settings list of switches

