Animated Switch
March 2025
An accessible animated switch component with press & disabled animation, powered by Radix & Motion.
Example
Controls
Code
"use client";
import { useState, forwardRef, useRef, useEffect } from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { animate, motion, MotionConfig } from "framer-motion";
import { cn } from "@/lib/utils";
const Switch = forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, disabled, ...props }, ref) => {
const thumbRef = useRef<HTMLDivElement>(null);
const [isPressed, setIsPressed] = useState(false);
const [isPointerInput, setIsPointerInput] = useState(false);
useEffect(() => {
if (!thumbRef.current) return;
if (disabled && isPressed) {
animate(
thumbRef.current,
{ x: [0, -2, 2, -1, 0] },
{
delay: 0.2,
duration: 0.6,
}
);
}
}, [isPressed, disabled]);
const pointerPressed = !disabled && isPointerInput && isPressed;
return (
<MotionConfig
transition={{
type: "spring",
stiffness: 800,
damping: 80,
mass: 4,
}}
>
<SwitchPrimitives.Root
asChild
disabled={disabled}
{...props}
ref={ref}
>
<motion.button
onPointerDown={(e) => {
setIsPressed(true);
setIsPointerInput(e.type.startsWith("pointer"));
}}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
initial={false}
className="group peer inline-flex h-7 w-12 shrink-0 cursor-pointer items-center justify-start rounded-[9px] px-[0.25rem] transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-slate-900 dark:data-[state=checked]:bg-slate-50 data-[state=unchecked]:bg-slate-400 dark:data-[state=unchecked]:bg-slate-600 data-[state=checked]:justify-end data-[state=unchecked]:justify-start"
>
<SwitchPrimitives.Thumb asChild>
<motion.div
ref={thumbRef}
layout
animate={{
scale: pointerPressed ? 0.9 : 1,
borderRadius:
pointerPressed ? 5 : 6,
}}
style={{
borderRadius: 6,
}}
className="pointer-events-none block bg-background shadow-lg ring-0"
>
<div
className={cn("size-5", {
'group-data-[state=unchecked]:mr-[4px] group-data-[state=checked]:ml-[4px]': pointerPressed,
})}
/>
</motion.div>
</SwitchPrimitives.Thumb>
</motion.button>
</SwitchPrimitives.Root>
</MotionConfig>
);
});
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };