trove/cn

Switch

A draggable on/off control for settings and preferences.

Sizes

The two size steps, sm and default.

sizes

Settings row

Label, description, and switch laid out as a settings list item.

settings-row

Install

Install the packages this component imports.

bun add motion @base-ui/react
Terminal

Also requires lib/utils.ts, lib/springs.ts (see Installation).

Source

Copy and paste the following code into your project.

"use client";

import { useEffect, useRef, useState } from "react";
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { motion, useMotionValue, useReducedMotion, animate } from "motion/react";

import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";

const SIZES = {
  default: { track: 34, height: 20, thumb: 16 },
  sm: { track: 24, height: 14, thumb: 12 },
} as const;

const DRAG_DEAD_ZONE = 2;

function Switch({
  className,
  size = "default",
  checked: checkedProp,
  defaultChecked,
  onCheckedChange,
  disabled = false,
  ...props
}: Omit<SwitchPrimitive.Root.Props, "inputRef"> & {
  size?: "sm" | "default";
}) {
  const { track, height, thumb } = SIZES[size];
  const offset = (height - thumb) / 2;
  const travel = track - thumb - offset * 2;
  const hoverExtend = (2 * thumb) / 16;
  const pressExtend = (4 * thumb) / 16;
  const pressShrink = (4 * thumb) / 16;

  const [uncontrolledChecked, setUncontrolledChecked] = useState(defaultChecked ?? false);
  const checked = checkedProp ?? uncontrolledChecked;

  const applyChecked = (next: boolean, eventDetails?: SwitchPrimitive.Root.ChangeEventDetails) => {
    if (checkedProp === undefined) setUncontrolledChecked(next);
    onCheckedChange?.(next, eventDetails as SwitchPrimitive.Root.ChangeEventDetails);
  };

  const [hovered, setHovered] = useState(false);
  const [pressed, setPressed] = useState(false);
  const dragging = useRef(false);
  const suppressNextClick = useRef(false);
  const dragStart = useRef<{ clientX: number; originX: number } | null>(null);

  const thumbWidth = pressed ? thumb + pressExtend : hovered ? thumb + hoverExtend : thumb;
  const thumbHeight = pressed ? thumb - pressShrink : thumb;
  const thumbY = pressed ? offset + pressShrink / 2 : offset;
  const thumbX = checked ? offset + travel - (thumbWidth - thumb) : offset;

  const motionX = useMotionValue(thumbX);
  const reduceMotion = useReducedMotion();
  const hasMountedRef = useRef(false);
  useEffect(() => {
    hasMountedRef.current = true;
  }, []);

  useEffect(() => {
    if (dragging.current) return;
    if (!hasMountedRef.current) {
      motionX.set(thumbX);
      return;
    }
    const controls = animate(motionX, thumbX, spring.moderate.enter);
    return () => controls.stop();
    // oxlint-disable-next-line react-hooks/exhaustive-deps
  }, [thumbX]);

  return (
    <SwitchPrimitive.Root
      data-slot="switch"
      data-size={size}
      checked={checked}
      disabled={disabled}
      onCheckedChange={(next, eventDetails) => {
        if (suppressNextClick.current) return;
        applyChecked(next, eventDetails);
      }}
      className={cn(
        "group/switch relative shrink-0 touch-none rounded-full border border-transparent outline-none transition-colors after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
        checked
          ? hovered
            ? "bg-accent-blue-hover"
            : "bg-accent-blue"
          : hovered
            ? "bg-[color-mix(in_oklch,var(--input),var(--foreground)_10%)] dark:bg-[color-mix(in_oklch,var(--input),var(--foreground)_14%)]"
            : "bg-input",
        className,
      )}
      style={{ width: track, height }}
      onPointerEnter={(e) => {
        if (!disabled && e.pointerType === "mouse") setHovered(true);
      }}
      onPointerLeave={() => setHovered(false)}
      onPointerDown={(e) => {
        if (disabled) return;
        if (e.pointerType === "mouse" && e.button !== 0) return;
        setPressed(true);
        dragging.current = false;
        suppressNextClick.current = false;
        dragStart.current = { clientX: e.clientX, originX: motionX.get() };
        e.currentTarget.setPointerCapture(e.pointerId);
      }}
      onPointerMove={(e) => {
        if (!dragStart.current) return;
        const delta = e.clientX - dragStart.current.clientX;
        if (!dragging.current) {
          if (Math.abs(delta) < DRAG_DEAD_ZONE) return;
          dragging.current = true;
        }
        const pressedWidth = thumb + pressExtend;
        const min = offset;
        const max = track - offset - pressedWidth;
        motionX.set(Math.max(min, Math.min(max, dragStart.current.originX + delta)));
      }}
      onPointerUp={() => {
        if (!dragStart.current) return;
        setPressed(false);
        if (dragging.current) {
          suppressNextClick.current = true;
          dragging.current = false;
          const pressedWidth = thumb + pressExtend;
          const min = offset;
          const max = track - offset - pressedWidth;
          const shouldBeOn = motionX.get() > (min + max) / 2;
          if (shouldBeOn !== checked) {
            applyChecked(shouldBeOn);
          } else {
            animate(
              motionX,
              checked ? offset + travel - pressExtend : offset,
              spring.moderate.enter,
            );
          }
          requestAnimationFrame(() => {
            suppressNextClick.current = false;
          });
        }
        dragStart.current = null;
      }}
      onPointerCancel={() => {
        if (!dragStart.current) return;
        setPressed(false);
        if (dragging.current) {
          dragging.current = false;
          animate(motionX, thumbX, spring.moderate.enter);
        }
        dragStart.current = null;
      }}
      {...props}
    >
      <motion.span
        data-slot="switch-thumb"
        className="pointer-events-none absolute -top-px -left-px block rounded-full bg-background shadow-sm dark:bg-foreground"
        style={{ x: motionX }}
        animate={{ y: thumbY, width: thumbWidth, height: thumbHeight }}
        transition={
          !hasMountedRef.current || reduceMotion ? { duration: 0 } : spring.moderate.enter
        }
      />
    </SwitchPrimitive.Root>
  );
}

export { Switch };
switch.tsx

API Reference

Switch
size"default"

"sm" | "default"

Track/thumb size step.

checked

boolean

Controlled checked state.

defaultCheckedfalse

boolean

Initial checked state, uncontrolled.

onCheckedChange

(checked, eventDetails) => void

Called when the switch is toggled, by click or by drag.

disabledfalse

boolean

Whether the switch can be toggled.