Animated Switch

March 2025

An accessible animated switch component with press & disabled animation, powered by Radix & Motion.

Example

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 };