Switch

Animated toggle switch with a spring-driven thumb and smooth fill transition. Built on Radix UI Switch.

Made by Léo
Open in
demo-switch.tsx
"use client";

import { useState } from "react";
import { Switch } from "@/components/unlumen-ui/switch";

export const SwitchDemo = ({
  labelSide = "right",
}: {
  labelSide?: "left" | "right";
}) => {
  const [wifi, setWifi] = useState(true);
  const [bluetooth, setBluetooth] = useState(false);
  const [notifications, setNotifications] = useState(true);
  const [darkMode, setDarkMode] = useState(false);

  return (
    <div className="flex flex-col gap-4 p-8">
      <Switch
        checked={wifi}
        onCheckedChange={setWifi}
        label="Wi-Fi"
        labelSide={labelSide}
      />
      <Switch
        checked={bluetooth}
        onCheckedChange={setBluetooth}
        label="Bluetooth"
        labelSide={labelSide}
      />
      <Switch
        checked={notifications}
        onCheckedChange={setNotifications}
        label="Notifications"
        labelSide={labelSide}
      />
      <Switch
        checked={darkMode}
        onCheckedChange={setDarkMode}
        label="Dark mode"
        labelSide={labelSide}
      />
    </div>
  );
};

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/switch.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 };

Update the import paths to match your project setup.

Usage

import { Switch } from "@/components/unlumen-ui/switch";
const [enabled, setEnabled] = useState(false);

<Switch checked={enabled} onCheckedChange={setEnabled} />;

With label

<Switch
  checked={enabled}
  onCheckedChange={setEnabled}
  label="Notifications"
/>

// Label on the left
<Switch
  checked={enabled}
  onCheckedChange={setEnabled}
  label="Dark mode"
  labelSide="left"
/>

Props

PropTypeDefaultDescription
checkedbooleanControlled checked state.
onCheckedChange(v: boolean) => voidCalled when the switch is toggled.
labelstringText label rendered beside the switch.
labelSide"left" | "right""right"Which side of the switch the label sits.
disabledbooleanfalseDisables interaction.
classNamestringExtra classes on the switch root.

Built by Léo from Unlumen :3

Last updated: 3/15/2026