DesignPass.dev

ui

Free

SpringSlider

Channel-style slider — a pill thumb rides inside a full-height track, chasing the pointer on a spring with velocity squash. Optional step ticks and in-thumb value readout.

// preview

// settings

Size (px)
Thumb width (px)
Step

// source

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

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

export interface SpringSliderProps {
  /** Controlled value. Omit to let the slider manage its own state. */
  value?: number;
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  onChange?: (value: number) => void;
  /** Control height in px — the track is the full height, like a channel
   * the thumb rides in. */
  size?: number;
  /** Thumb width in px. Defaults to a circle (size minus inset). */
  thumbWidth?: number;
  /** Render a tick mark at every step (skipped when steps are too dense). */
  showSteps?: boolean;
  /** Render the current value inside the thumb. Pair with a wider
   * thumbWidth so the number has room. */
  showValue?: boolean;
  disabled?: boolean;
  /** Accessible name, e.g. "Volume". */
  ariaLabel?: string;
  className?: string;
}

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

// Tight spring while dragging (the thumb chases the pointer with a hint of
// lag), loose spring on release/keyboard so it overshoots and settles —
// same motion language as SlideToggle and Magnet.
const DRAG_STIFFNESS = 0.5;
const DRAG_DAMPING = 0.55;
const RELEASE_STIFFNESS = 0.14;
const RELEASE_DAMPING = 0.78;

// Gap between the thumb and the track walls, matching SlideToggle.
const INSET = 3;

// Past this many ticks they read as noise, so showSteps is ignored.
const MAX_TICKS = 41;

export default function SpringSlider({
  value,
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  onChange,
  size = 24,
  thumbWidth,
  showSteps = false,
  showValue = false,
  disabled = false,
  ariaLabel,
  className = "",
}: SpringSliderProps) {
  const [internalValue, setInternalValue] = useState(defaultValue ?? min);
  const current = clamp(value ?? internalValue, min, max);
  const fraction = max > min ? (current - min) / (max - min) : 0;
  const thumbW = thumbWidth ?? size - INSET * 2;

  const trackRef = useRef<HTMLDivElement>(null);
  const thumbRef = useRef<HTMLDivElement>(null);
  const fillRef = useRef<HTMLDivElement>(null);

  // Spring state lives outside React so dragging never re-renders beyond
  // the committed value changes.
  const physics = useRef({
    position: fraction, // 0..1 along the track
    velocity: 0,
    target: fraction,
    dragging: false,
    frame: 0,
    running: false,
    reducedMotion: false,
  });

  const commit = useCallback(
    (next: number) => {
      const snapped = clamp(Math.round((next - min) / step) * step + min, min, max);
      // Avoid float drift like 0.30000000000000004 in committed values.
      const rounded = Number(snapped.toFixed(6));
      if (rounded !== (value ?? internalValue)) {
        setInternalValue(rounded);
        onChange?.(rounded);
      }
      return rounded;
    },
    [min, max, step, value, internalValue, onChange],
  );

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

      const travel = track.clientWidth - INSET * 2 - thumbW;
      // Velocity-based squash & stretch: a fast thumb goes long and flat.
      const stretch = clamp(Math.abs(velocity) * 2, 0, 0.3);
      thumb.style.transform =
        `translateX(${position * travel}px) scaleX(${1 + stretch}) scaleY(${1 - stretch * 0.5})`;
      // Fill reaches the thumb's center so it never peeks past the pill.
      fill.style.width = `${INSET + position * travel + thumbW / 2}px`;
    },
    [thumbW],
  );

  /** 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.0005 &&
        Math.abs(state.target - state.position) < 0.0005;

      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 = fraction;
    wake();
    return () => {
      cancelAnimationFrame(state.frame);
      state.running = false;
    };
  }, [fraction, wake]);

  // Drag anywhere on the track: the thumb springs to the pointer and the
  // value commits continuously (snapped to step) while you drag.
  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 fractionFromPointer = (clientX: number) =>
      clamp(
        (clientX - rect.left - INSET - thumbW / 2) / (rect.width - INSET * 2 - thumbW),
        0,
        1,
      );

    state.dragging = true;
    state.target = fractionFromPointer(event.clientX);
    commit(min + state.target * (max - min));
    wake();

    const onMove = (e: PointerEvent) => {
      state.target = fractionFromPointer(e.clientX);
      commit(min + state.target * (max - min));
      wake();
    };

    const onUp = () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      state.dragging = false;
      // Land exactly on the committed (snapped) value.
      const committed = commit(min + state.target * (max - min));
      state.target = max > min ? (committed - min) / (max - min) : 0;
      wake();
    };

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

  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    if (disabled) return;
    let next: number | null = null;
    if (event.key === "ArrowRight" || event.key === "ArrowUp") next = current + step;
    else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = current - step;
    else if (event.key === "Home") next = min;
    else if (event.key === "End") next = max;
    if (next !== null) {
      event.preventDefault();
      commit(next);
    }
  }

  const tickCount = Math.round((max - min) / step) + 1;
  const ticks =
    showSteps && tickCount <= MAX_TICKS && tickCount > 2
      ? Array.from({ length: tickCount }, (_, i) => i / (tickCount - 1))
      : [];

  return (
    <div
      ref={trackRef}
      role="slider"
      aria-label={ariaLabel}
      aria-valuemin={min}
      aria-valuemax={max}
      aria-valuenow={current}
      tabIndex={disabled ? -1 : 0}
      onPointerDown={onPointerDown}
      onKeyDown={onKeyDown}
      style={{ height: `${size}px` }}
      className={`relative w-full cursor-pointer select-none touch-none overflow-hidden rounded-full border border-white/10 bg-white/5 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}`}
    >
      {/* Fill — tinted channel behind the thumb's trail. */}
      <div
        ref={fillRef}
        aria-hidden="true"
        className="absolute inset-y-0 left-0 rounded-l-full bg-white/15"
      />
      {/* Step ticks. */}
      {ticks.map((f) => (
        <span
          key={f}
          aria-hidden="true"
          className="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/25"
          style={{
            left: `calc(${INSET + thumbW / 2}px + (100% - ${INSET * 2 + thumbW}px) * ${f})`,
          }}
        />
      ))}
      {/* Thumb — a pill riding inside the channel, SlideToggle-style. */}
      <div
        ref={thumbRef}
        aria-hidden="true"
        style={{ width: `${thumbW}px`, fontSize: `${Math.max(9, Math.round(size * 0.38))}px` }}
        className="absolute inset-y-[3px] left-[3px] flex items-center justify-center rounded-full bg-white font-mono text-[#14101d] shadow-[0_1px_4px_rgba(0,0,0,0.35)] will-change-transform"
      >
        {showValue ? current : null}
      </div>
    </div>
  );
}

// install

npx shadcn@latest add "https://designpass.dev/r/SpringSlider-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.