Accessible on/off switch with a spring-driven thumb, three sizes, custom accent track color and both controlled and uncontrolled usage.
Preview
Customize
5 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add toggle-switchWrites components/ui/toggle-switch.tsx and components/styles/toggle-switch.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ToggleSwitch } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ToggleSwitch />6 );7}Props
Accessibility
Renders a <button role="switch"> with aria-checked; the visible label is part of the button so it is the accessible name (falls back to aria-label "Toggle"). Space/Enter toggle natively, disabled uses the native attribute, and prefers-reduced-motion moves the thumb instantly.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/toggle-switch.module.css";6 7export interface ToggleSwitchProps {8 /** Controlled on/off state */9 checked?: boolean;10 /** Initial state when uncontrolled */11 defaultChecked?: boolean;12 /** Called with the next state when toggled */13 onCheckedChange?: (checked: boolean) => void;14 /** Visible label (also the accessible name) */15 label?: string;16 /** Switch size */17 size?: "sm" | "md" | "lg";18 /** Track color when on */19 accentColor?: string;20 /** Disable interaction */21 disabled?: boolean;22 /** Optional className */23 className?: string;24}25 26const SIZES = {27 sm: { w: 36, h: 20 },28 md: { w: 48, h: 26 },29 lg: { w: 60, h: 32 },30} as const;31 32const PAD = 3;33 34export function ToggleSwitch({35 checked,36 defaultChecked = false,37 onCheckedChange,38 label = "Reduce motion",39 size = "md",40 accentColor = "#38bdf8",41 disabled = false,42 className = "",43}: ToggleSwitchProps) {44 const prefersReducedMotion = useReducedMotion();45 const [inner, setInner] = React.useState(defaultChecked);46 const isControlled = checked !== undefined;47 const on = isControlled ? checked : inner;48 const dims = SIZES[size] ?? SIZES.md;49 const thumb = dims.h - PAD * 2;50 51 const toggle = () => {52 if (disabled) return;53 const next = !on;54 if (!isControlled) setInner(next);55 onCheckedChange?.(next);56 };57 58 return (59 <button60 type="button"61 role="switch"62 aria-checked={on}63 aria-label={label ? undefined : "Toggle"}64 disabled={disabled}65 onClick={toggle}66 data-state={on ? "on" : "off"}67 style={68 {69 "--mf-ts-accent": accentColor,70 "--mf-ts-w": `${dims.w}px`,71 "--mf-ts-h": `${dims.h}px`,72 "--mf-ts-thumb": `${thumb}px`,73 "--mf-ts-pad": `${PAD}px`,74 } as React.CSSProperties75 }76 className={`${styles.root} ${styles[size] ?? ""} ${className}`.trim()}77 >78 <span className={styles.track} aria-hidden="true">79 <motion.span80 className={styles.thumb}81 initial={false}82 animate={{ x: on ? dims.w - dims.h : 0, scale: 1 }}83 whileTap={prefersReducedMotion || disabled ? undefined : { scale: 0.9 }}84 transition={prefersReducedMotion ? { duration: 0 } : { type: "spring", stiffness: 700, damping: 32 }}85 />86 </span>87 {label ? <span className={styles.label}>{label}</span> : null}88 </button>89 );90}