DesignPass.dev

ui

Free

SlideToggle

Two-option toggle with a draggable, spring-loaded thumb that squashes and stretches with velocity, leans toward the side you hover, and works with or without labels. Three sizes.

// preview

LightDark

selected: dark — tap, drag the thumb, or press space

// settings

Size (px)

Omit both labels to use it as a bare on/off switch. Hover a side and the thumb leans toward it; drag it and the labels crossfade with the live position.

// source

TSJS
TailwindCSS
/*!
 * SlideToggle — a DesignPass.dev component by Ernest Liu
 * Docs & live playground: https://designpass.dev/components/slide-toggle
 * MIT licensed — keep this notice in copies and adaptations.
 */
"use client";

import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";

export interface SlideToggleOption<T extends string> {
  value: T;
  /** Optional — omit labels on both options for a bare on/off switch. */
  label?: ReactNode;
}

export interface SlideToggleProps<T extends string> {
  /** Exactly two options; the thumb slides between them. */
  options: readonly [SlideToggleOption<T>, SlideToggleOption<T>];
  /** Controlled value. Omit to let the toggle manage its own state. */
  value?: T;
  defaultValue?: T;
  onChange?: (value: T) => void;
  /** Control height in px; everything else scales from it. */
  size?: number;
  disabled?: boolean;
  /** Accessible name for the group, e.g. "Language". */
  ariaLabel?: string;
  className?: string;
}

const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);

// Tight spring while the pointer is dragging the thumb, loose spring on
// release so it overshoots its slot and wobbles back — same motion language
// as our Magnet component.
const DRAG_STIFFNESS = 0.4;
const DRAG_DAMPING = 0.6;
const RELEASE_STIFFNESS = 0.14;
const RELEASE_DAMPING = 0.78;

// How far the thumb leans toward the other side while you hover over it —
// a small "come here" affordance before any click.
const HOVER_LEAN = 0.08;

const THUMB_INSET = 3; // px padding between thumb and track edge

// Label ink crossfades between these as the thumb slides underneath:
// dark ink on the solid white thumb, soft white off it.
const INK_ON_THUMB = { r: 20, g: 16, b: 29, a: 1 };
const INK_OFF_THUMB = { r: 255, g: 255, b: 255, a: 0.65 };

/** Everything scales off the control height so any size stays proportioned. */
function sizeStyles(size: number, hasLabels: boolean) {
  return {
    container: {
      height: `${size}px`,
      minWidth: hasLabels ? `${size * 4.6}px` : `${size * 2}px`,
    },
    label: {
      fontSize: `${Math.max(9, Math.round(size * 0.38))}px`,
      padding: `0 ${Math.round(size * 0.45)}px`,
    },
  };
}

export default function SlideToggle<T extends string>({
  options,
  value,
  defaultValue,
  onChange,
  size = 28,
  disabled = false,
  ariaLabel,
  className = "",
}: SlideToggleProps<T>) {
  const [internalValue, setInternalValue] = useState<T>(defaultValue ?? options[0].value);
  const selected = value ?? internalValue;
  const selectedIndex = selected === options[1].value ? 1 : 0;
  const hasLabels = options[0].label != null || options[1].label != null;

  const trackRef = useRef<HTMLDivElement>(null);
  const thumbRef = useRef<HTMLDivElement>(null);
  const labelRefs = useRef<(HTMLSpanElement | null)[]>([null, null]);

  // Spring state lives outside React so drag/animation never re-renders.
  const physics = useRef({
    position: selectedIndex, // 0..1 = left..right slot
    velocity: 0,
    target: selectedIndex,
    dragging: false,
    frame: 0,
    running: false,
    reducedMotion: false,
  });

  const select = useCallback(
    (next: T) => {
      if (next !== (value ?? internalValue)) {
        setInternalValue(next);
        onChange?.(next);
        // A tiny tactile tick on devices that support it.
        if (typeof navigator !== "undefined") navigator.vibrate?.(8);
      }
    },
    [value, internalValue, onChange],
  );

  /** Paint thumb + label ink for a given spring position/velocity. */
  const render = useCallback((position: number, velocity: number) => {
    const track = trackRef.current;
    const thumb = thumbRef.current;
    if (!track || !thumb) return;

    const travel = track.clientWidth / 2 - THUMB_INSET;
    // Velocity-based squash & stretch: a fast thumb goes long and flat.
    const stretch = clamp(Math.abs(velocity) * 1.4, 0, 0.22);
    thumb.style.transform =
      `translateX(${position * travel}px) scaleX(${1 + stretch}) scaleY(${1 - stretch * 0.6})`;

    // Label ink follows the live thumb position, not the committed state,
    // so a drag crossfades the labels in real time.
    for (let i = 0; i < 2; i++) {
      const label = labelRefs.current[i];
      if (!label) continue;
      const p = clamp(i === 0 ? 1 - position : position, 0, 1);
      const r = Math.round(INK_OFF_THUMB.r + (INK_ON_THUMB.r - INK_OFF_THUMB.r) * p);
      const g = Math.round(INK_OFF_THUMB.g + (INK_ON_THUMB.g - INK_OFF_THUMB.g) * p);
      const b = Math.round(INK_OFF_THUMB.b + (INK_ON_THUMB.b - INK_OFF_THUMB.b) * p);
      const a = INK_OFF_THUMB.a + (INK_ON_THUMB.a - INK_OFF_THUMB.a) * p;
      label.style.color = `rgba(${r}, ${g}, ${b}, ${a})`;
    }
  }, []);

  /** Single owner of the animation loop; safe to call repeatedly. */
  const wake = useCallback(() => {
    const state = physics.current;

    if (state.reducedMotion) {
      state.position = state.target;
      render(state.position, 0);
      return;
    }
    if (state.running) return;
    state.running = true;

    const tick = () => {
      const k = state.dragging ? DRAG_STIFFNESS : RELEASE_STIFFNESS;
      const d = state.dragging ? DRAG_DAMPING : RELEASE_DAMPING;
      state.velocity = (state.velocity + (state.target - state.position) * k) * d;
      state.position += state.velocity;
      render(state.position, state.velocity);

      const settled =
        !state.dragging &&
        Math.abs(state.velocity) < 0.001 &&
        Math.abs(state.target - state.position) < 0.001;

      if (settled) {
        state.position = state.target;
        render(state.position, 0);
        state.running = false;
        return;
      }
      state.frame = requestAnimationFrame(tick);
    };

    state.frame = requestAnimationFrame(tick);
  }, [render]);

  useEffect(() => {
    const state = physics.current;
    state.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    state.target = selectedIndex;
    wake();
    return () => {
      cancelAnimationFrame(state.frame);
      state.running = false;
    };
  }, [selectedIndex, wake]);

  // Pointer interaction: tap either side to select it, or grab the thumb
  // and slide — release commits to whichever slot is nearest.
  function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
    if (disabled) return;
    const track = trackRef.current;
    if (!track) return;

    const state = physics.current;
    const rect = track.getBoundingClientRect();
    const startX = event.clientX;
    let moved = false;

    const onMove = (e: PointerEvent) => {
      if (!moved && Math.abs(e.clientX - startX) < 4) return;
      moved = true;
      state.dragging = true;
      // Map the pointer to the thumb-center position along the track.
      const rel = (e.clientX - rect.left - rect.width / 4) / (rect.width / 2);
      state.target = clamp(rel, 0, 1);
      wake();
    };

    const onUp = (e: PointerEvent) => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      if (moved) {
        state.dragging = false;
        const nearest = state.position >= 0.5 ? 1 : 0;
        state.target = nearest;
        select(options[nearest].value);
      } else {
        const side = e.clientX < rect.left + rect.width / 2 ? 0 : 1;
        select(options[side].value);
      }
      wake();
    };

    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
  }

  // Hover lean: while hovering the inactive side, the thumb tips slightly
  // toward it — a pre-click hint that the surface responds.
  function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {
    if (disabled) return;
    const state = physics.current;
    if (state.dragging) return;
    const track = trackRef.current;
    if (!track) return;

    const rect = track.getBoundingClientRect();
    const side = event.clientX < rect.left + rect.width / 2 ? 0 : 1;
    state.target =
      side === selectedIndex
        ? selectedIndex
        : selectedIndex + (side - selectedIndex) * HOVER_LEAN;
    wake();
  }

  function onPointerLeave() {
    const state = physics.current;
    if (state.dragging) return;
    state.target = selectedIndex;
    wake();
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    if (disabled) return;
    if (event.key === " " || event.key === "Enter") {
      event.preventDefault();
      select(options[selectedIndex === 0 ? 1 : 0].value);
    }
  }

  return (
    <div
      ref={trackRef}
      role="radiogroup"
      aria-label={ariaLabel}
      tabIndex={disabled ? -1 : 0}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerLeave={onPointerLeave}
      onKeyDown={onKeyDown}
      style={sizeStyles(size, hasLabels).container}
      className={`relative inline-grid cursor-pointer select-none touch-none grid-cols-2 rounded-full border border-white/10 bg-white/5 p-[3px] outline-none transition-colors focus-visible:border-white/30 focus-visible:ring-2 focus-visible:ring-white/20 ${
        disabled ? "cursor-not-allowed opacity-40" : ""
      } ${className}`}
    >
      <div
        ref={thumbRef}
        aria-hidden="true"
        className="absolute inset-y-[3px] left-[3px] w-[calc(50%-3px)] rounded-full bg-white shadow-[0_1px_4px_rgba(0,0,0,0.35)] will-change-transform"
      />
      {options.map((option, index) => (
        <span
          key={option.value}
          role="radio"
          aria-checked={index === selectedIndex}
          aria-label={typeof option.label === "string" ? undefined : option.value}
          ref={(el) => {
            labelRefs.current[index] = el;
          }}
          style={sizeStyles(size, hasLabels).label}
          className="relative z-10 flex items-center justify-center text-center font-mono uppercase tracking-widest"
        >
          {option.label}
        </span>
      ))}
    </div>
  );
}

// install

npx shadcn@latest add "https://designpass.dev/r/SlideToggle-TS-TW.json"

Need the license details? Read the component license.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.