# Slider Spring Thumb — React Slider Component

> A slider whose value bubble trails the thumb on a loose spring and leans into the direction of travel, then settles upright.

- Category: [Sliders](https://pixelpapa.com/components/sliders.md)
- Tags: slider, spring, tooltip, range, drag
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/sliders/slider-spring-thumb

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/slider-spring-thumb.json"
```

The file is written to `components/pixelpapa/slider-spring-thumb.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Sun, Thermometer } from "lucide-react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from "motion/react";
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "@/lib/utils";

type SpringSliderProps = {
  value?: number;
  defaultValue?: number;
  onChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** "always" keeps the bubble visible; "active" shows it on hover, focus and drag. */
  tooltip?: "always" | "active";
  format?: (value: number) => string;
  label: string;
  className?: string;
};

export function SpringSlider({
  value,
  defaultValue = 50,
  onChange,
  min = 0,
  max = 100,
  step = 1,
  tooltip = "active",
  format = String,
  label,
  className,
}: SpringSliderProps) {
  const [inner, setInner] = useState(defaultValue);
  const [dragging, setDragging] = useState(false);
  const [hot, setHot] = useState(false);
  const track = useRef<HTMLDivElement>(null);
  const thumb = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const current = value ?? inner;
  const pct = ((current - min) / (max - min)) * 100;

  // The bubble chases this target on a spring; its velocity drives the lean.
  const target = useMotionValue(pct);
  const trailing = useSpring(target, { stiffness: 170, damping: 13, mass: 0.7 });
  const source = reduce ? target : trailing;
  const left = useTransform(source, (v) => `${v}%`);
  const velocity = useVelocity(trailing);
  const rotate = useTransform(velocity, [-260, 0, 260], reduce ? [0, 0, 0] : [26, 0, -26]);

  useEffect(() => {
    target.set(pct);
  }, [pct, target]);

  function set(next: number) {
    const snapped = Math.min(max, Math.max(min, Math.round((next - min) / step) * step + min));
    if (snapped === current) return;
    if (value === undefined) setInner(snapped);
    onChange?.(snapped);
  }

  function fromPointer(e: PointerEvent<HTMLDivElement>) {
    const rect = track.current?.getBoundingClientRect();
    if (rect) set(min + ((e.clientX - rect.left) / rect.width) * (max - min));
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const big = Math.max(step, (max - min) / 10);
    const moves: Record<string, number> = {
      ArrowRight: current + step,
      ArrowUp: current + step,
      ArrowLeft: current - step,
      ArrowDown: current - step,
      PageUp: current + big,
      PageDown: current - big,
      Home: min,
      End: max,
    };
    if (e.key in moves) {
      e.preventDefault();
      set(moves[e.key]);
    }
  }

  const visible = tooltip === "always" || dragging || hot;

  return (
    <div
      className={cn("relative flex h-6 w-full cursor-pointer touch-none items-center select-none", className)}
      onPointerDown={(e) => {
        e.currentTarget.setPointerCapture(e.pointerId);
        setDragging(true);
        fromPointer(e);
        thumb.current?.focus();
      }}
      onPointerMove={(e) => dragging && fromPointer(e)}
      onPointerUp={() => setDragging(false)}
      onPointerCancel={() => setDragging(false)}
      onPointerEnter={() => setHot(true)}
      onPointerLeave={() => setHot(false)}
    >
      <div ref={track} className="relative h-1.5 w-full rounded-full bg-muted">
        <div className="absolute inset-y-0 left-0 rounded-full bg-primary" style={{ width: `${pct}%` }} />
        <motion.div
          aria-hidden
          style={{ left, rotate, x: "-50%", transformOrigin: "50% 140%" }}
          animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0.6, y: visible ? 0 : 6 }}
          transition={{ type: "spring", stiffness: 420, damping: 26 }}
          className="pointer-events-none absolute bottom-full mb-4 rounded-lg bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background tabular-nums shadow-lg"
        >
          {format(current)}
          <span className="absolute top-full left-1/2 -mt-1 size-2 -translate-x-1/2 rotate-45 rounded-[2px] bg-foreground" />
        </motion.div>
        <div className="absolute top-1/2" style={{ left: `${pct}%` }}>
          <motion.div
            ref={thumb}
            role="slider"
            tabIndex={0}
            aria-label={label}
            aria-valuemin={min}
            aria-valuemax={max}
            aria-valuenow={current}
            aria-valuetext={format(current)}
            onKeyDown={onKeyDown}
            onFocus={() => setHot(true)}
            onBlur={() => setHot(false)}
            animate={{ scale: dragging ? 1.25 : 1 }}
            transition={{ type: "spring", stiffness: 500, damping: 22 }}
            className="-mt-2.5 -ml-2.5 size-5 rounded-full border-2 border-primary bg-background shadow-md outline-offset-4"
          />
        </div>
      </div>
    </div>
  );
}

export default function SliderSpringThumbDemo() {
  const [brightness, setBrightness] = useState(64);
  const [warmth, setWarmth] = useState(4200);

  return (
    <div className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-sm">
      <h3 className="text-lg font-semibold tracking-tight text-card-foreground">Display</h3>
      <p className="mt-1 text-sm text-muted-foreground">Drag quickly and watch the label catch up.</p>

      <div className="mt-12 flex items-center gap-3">
        <Sun className="size-4 shrink-0 text-muted-foreground" />
        <SpringSlider label="Brightness" tooltip="always" value={brightness} onChange={setBrightness} format={(v) => `${v}%`} />
      </div>
      <div className="mt-2 flex justify-between pl-7 text-xs text-muted-foreground">
        <span>Brightness</span>
        <span>Auto-dims after 2 min</span>
      </div>

      <div className="mt-12 flex items-center gap-3">
        <Thermometer className="size-4 shrink-0 text-muted-foreground" />
        <SpringSlider
          label="Colour temperature"
          tooltip="always"
          min={2700}
          max={6500}
          step={100}
          value={warmth}
          onChange={setWarmth}
          format={(v) => `${v.toLocaleString("en-US")} K`}
        />
      </div>
      <div className="mt-2 flex justify-between pl-7 text-xs text-muted-foreground">
        <span>Warm</span>
        <span>Cool</span>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Slider Spring Thumb" component from PixelPapa UI to this project.

What it is: A slider whose value bubble trails the thumb on a loose spring and leans into the direction of travel, then settles upright.
Category: Sliders. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/slider-spring-thumb.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/slider-spring-thumb.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/slider-spring-thumb" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More sliders

- [Slider Circular Dial](https://pixelpapa.com/components/sliders/slider-circular-dial.md) (Pro): A thermostat-style dial: drag the knob around a 270° arc and the gradient track, knob and reading follow on a spring; presets sweep it to a new value.
- [Slider Range Dual](https://pixelpapa.com/components/sliders/slider-range-dual.md) (Pro): A two-thumb range slider under a histogram whose bars light up inside the selection; thumbs keep a minimum gap and the nearest one follows a track press.
- [Slider Ruler Ticks](https://pixelpapa.com/components/sliders/slider-ruler-ticks.md) (Pro): A ruler you drag or flick under a fixed needle; it coasts with inertia, snaps to the nearest tick and shows the reading large in the centre.
- [Slider Stepped Labels](https://pixelpapa.com/components/sliders/slider-stepped-labels.md): A discrete slider with labelled stops: the thumb follows your finger freely, then springs onto the nearest stop while its label lights up.
- [Slider Volume Vertical](https://pixelpapa.com/components/sliders/slider-volume-vertical.md): A tall pill you drag up and down like a phone volume control; the fill follows on a spring, the pill stretches when you pull past either end, and M mutes.
