shadimage

Switch

Animated toggle switch with a spring-driven thumb, smooth fill transition, and optional label.

registry/components/unlumen/switch/index.tsx

"use client";

import * as SwitchPrimitive from "@radix-ui/react-switch";
import { motion, useMotionValue, useTransform, animate } from "motion/react";
import { forwardRef, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";

const TRACK_W = 44;
const TRACK_H = 26;
const THUMB_SIZE = 20;
const THUMB_TRAVEL = TRACK_W - THUMB_SIZE - 4; // px from left to right (margin 2px each side)

const spring = { type: "spring" as const, duration: 0.35, bounce: 0.3 };
const springFast = { type: "spring" as const, duration: 0.15, bounce: 0 };
const springSnap = { type: "spring" as const, duration: 0.4, bounce: 0.5 };

interface SwitchProps
  extends Omit<
    React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>,
    "asChild"
  > {
  label?: string;
  /** @default "right" */
  labelSide?: "left" | "right";
}

const Switch = forwardRef<
  React.ElementRef<typeof SwitchPrimitive.Root>,
  SwitchProps
>(
  (
    {
      checked,
      onCheckedChange,
      label,
      labelSide = "right",
      className,
      disabled,
      ...props
    },
    ref,
  ) => {
    const isControlled = checked !== undefined;
    const internalChecked = isControlled ? checked : false;

    const thumbX = useMotionValue(internalChecked ? THUMB_TRAVEL : 0);
    const thumbScaleX = useMotionValue(1);
    const thumbScaleY = useMotionValue(1);

    const fillOpacity = useTransform(thumbX, [0, THUMB_TRAVEL], [0, 1]);

    const prevChecked = useRef(internalChecked);
    const directionRef = useRef<1 | -1>(1);

    useEffect(() => {
      if (prevChecked.current === internalChecked) return;
      prevChecked.current = internalChecked;
      animate(thumbX, internalChecked ? THUMB_TRAVEL : 0, spring);
    }, [internalChecked, thumbX]);

    const handlePointerDown = () => {
      // Flatten thumb on press (squeeze effect)
      animate(thumbScaleX, 0.82, springFast);
      animate(thumbScaleY, 1.1, springFast);
    };

    const handlePointerUp = () => {
      // Snap back with bounce
      animate(thumbScaleX, 1, springSnap);
      animate(thumbScaleY, 1, springSnap);
    };

    const handleCheckedChange = (next: boolean) => {
      directionRef.current = next ? 1 : -1;

      // Squeeze in direction of travel on release
      animate(thumbScaleX, 1.15, springFast).then(() => {
        animate(thumbScaleX, 1, springSnap);
      });
      animate(thumbScaleY, 0.88, springFast).then(() => {
        animate(thumbScaleY, 1, springSnap);
      });

      animate(thumbX, next ? THUMB_TRAVEL : 0, spring);
      onCheckedChange?.(next);
    };

    const switchEl = (
      <SwitchPrimitive.Root
        ref={ref}
        checked={checked}
        onCheckedChange={handleCheckedChange}
        disabled={disabled}
        onPointerDown={handlePointerDown}
        onPointerUp={handlePointerUp}
        onPointerLeave={handlePointerUp}
        className={cn(
          "relative inline-flex shrink-0 cursor-pointer items-center rounded-full",
          "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          "disabled:cursor-not-allowed disabled:opacity-50",
          className,
        )}
        style={{ width: TRACK_W, height: TRACK_H }}
        {...props}
      >
        <span
          className="absolute inset-0 rounded-full"
          style={{ backgroundColor: "var(--accent)" }}
        />

        <motion.span
          className="absolute inset-0 rounded-full"
          style={{
            backgroundColor: "var(--foreground)",
            opacity: fillOpacity,
          }}
        />

        <SwitchPrimitive.Thumb asChild>
          <motion.span
            className="block rounded-full pointer-events-none z-10"
            style={{
              width: THUMB_SIZE,
              height: THUMB_SIZE,
              x: thumbX,
              scaleX: thumbScaleX,
              scaleY: thumbScaleY,
              marginLeft: 2,
              backgroundColor: "white",
              boxShadow:
                "0 1px 4px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.06)",
            }}
          />
        </SwitchPrimitive.Thumb>
      </SwitchPrimitive.Root>
    );

    if (!label) return switchEl;

    return (
      <label
        className={cn(
          "flex items-center gap-2.5 cursor-pointer select-none",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {labelSide === "left" && (
          <span className="text-sm text-foreground">{label}</span>
        )}
        {switchEl}
        {labelSide === "right" && (
          <span className="text-sm text-foreground">{label}</span>
        )}
      </label>
    );
  },
);

Switch.displayName = "Switch";

export { Switch, type SwitchProps };

No selection tags yet — empty means no evidence, not lack of support.

DependenciesBundle weight · medium

animation
motion
ui-primitives
@radix-ui/react-switch