# Elastic Slider

> A range slider with give: the value bubble leans as you drag, and pulling past the ends stretches the track like rubber.

Category: Inputs · Free · Updated 2026-10-21 · Live demo: https://motionkitui.com/components/elastic-slider

## When to use it

Volume, price ranges, opacity, zoom and any settings value, where a slider should feel physical rather than flat.

## How it works

A native range input sits invisibly over the drawing, so keyboard, touch and screen readers behave exactly as normal. The thumb position runs through a spring, and useVelocity turns how fast it's moving into a tilt for the value bubble. While dragging, the window tracks the pointer: pull past either end and the track stretches by a fraction of the overshoot, anchored at the far end, then springs back on release.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/elastic-slider.json
```

Or copy `components/motionkit/elastic-slider.tsx` below. Requires react, motion and Tailwind CSS v4.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` |  | Visible label and accessible name. |
| `min / max / step` | `number` | `0 / 100 / 1` | The range and step size. |
| `defaultValue` | `number` | `50` | Starting value. |
| `value` | `number` |  | Controlled value. |
| `onValueChange` | `(value: number) => void` |  | Called as the value changes. |
| `format` | `(value: number) => string` |  | Formats the bubble and the spoken value. |
| `color` | `string` | `"#5FAEB4"` | Fill and thumb colour. |

## Accessibility

- **Keyboard: Pass.** A native range input underneath: arrow keys, Page Up and Down, Home and End all work, with a focus ring around the track.
- **Screen readers: Pass.** Read as a slider with its label and formatted value (aria-valuetext), e.g. "Volume, 60%".
- **Reduced motion: Pass.** With reduced motion on, the track never stretches, the bubble stays upright and the thumb doesn't grow.
- **Colour contrast: Pass.** The teal fill measures 7.5:1 against the page, and the bubble's dark text 7.5:1 on teal.

## Source

```tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from "motion/react";

type ElasticSliderProps = {
  /** Visible label, also the accessible name. */
  label: string;
  min?: number;
  max?: number;
  step?: number;
  defaultValue?: number;
  /** Controlled value. Leave unset to let the slider manage itself. */
  value?: number;
  onValueChange?: (value: number) => void;
  /** Formats the number in the bubble and for screen readers, e.g. (v) => `${v}%`. */
  format?: (value: number) => string;
  /** Fill and thumb colour. */
  color?: string;
  className?: string;
};

/**
 * A range slider with some give: the value bubble leans with how fast you drag, and pulling past
 * either end stretches the track like rubber before it springs back. A native range input does
 * the real work, so keyboard, touch and screen readers behave exactly as they should.
 */
export function ElasticSlider({
  label,
  min = 0,
  max = 100,
  step = 1,
  defaultValue = 50,
  value: controlled,
  onValueChange,
  format = (v) => String(v),
  color = "#5FAEB4",
  className = "",
}: ElasticSliderProps) {
  const id = useId();
  const reduceMotion = useReducedMotion();
  const trackRef = useRef<HTMLDivElement>(null);
  const [own, setOwn] = useState(defaultValue);
  const [dragging, setDragging] = useState(false);
  const value = controlled ?? own;
  const percent = ((value - min) / (max - min)) * 100;

  // the thumb position eases after the value, and its speed tilts the bubble
  const position = useSpring(percent, { stiffness: 500, damping: 40 });
  const tilt = useTransform(useVelocity(position), [-400, 400], [18, -18], { clamp: true });
  const smoothTilt = useSpring(tilt, { stiffness: 300, damping: 20 });
  const left = useTransform(position, (p) => `${p}%`);

  // how far the pointer has pulled past the ends: positive past the right, negative past the left
  const overdrag = useMotionValue(0);
  const stretch = useSpring(overdrag, { stiffness: 400, damping: 22 });
  const scaleX = useTransform(stretch, (v) => 1 + Math.min(Math.abs(v), 80) / 600);
  const originX = useTransform(stretch, (v) => (v < 0 ? 1 : 0));

  useEffect(() => {
    position.set(percent);
  }, [percent, position]);

  // while dragging, watch the pointer on the window so the stretch keeps working outside the track
  useEffect(() => {
    if (!dragging || reduceMotion) return;
    function move(event: PointerEvent) {
      const rect = trackRef.current?.getBoundingClientRect();
      if (!rect) return;
      if (event.clientX < rect.left) overdrag.set(event.clientX - rect.left);
      else if (event.clientX > rect.right) overdrag.set(event.clientX - rect.right);
      else overdrag.set(0);
    }
    function up() {
      setDragging(false);
      overdrag.set(0);
    }
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    return () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
    };
  }, [dragging, reduceMotion, overdrag]);

  function change(next: number) {
    if (controlled === undefined) setOwn(next);
    onValueChange?.(next);
  }

  return (
    <div className={`w-full text-[#F6F1E8] ${className}`}>
      <div className="mb-3 flex items-baseline justify-between text-sm">
        <label htmlFor={id} className="text-[#DCE3E2]">
          {label}
        </label>
        <span aria-hidden className="font-mono text-xs text-[#94A9A7]">
          {format(value)}
        </span>
      </div>

      <div className="relative h-10">
        {/* only the track stretches; the thumb and bubble ride above it unscaled */}
        <motion.div
          ref={trackRef}
          className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-white/10"
          style={reduceMotion ? undefined : { scaleX, originX }}
        >
          <motion.div className="absolute inset-y-0 left-0 rounded-full" style={{ width: left, backgroundColor: color }} />
        </motion.div>
        <motion.div
          aria-hidden
          className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[#0C1010] shadow-[0_2px_10px_rgb(0_0_0/0.4)]"
          style={{ left, backgroundColor: color }}
          animate={{ scale: dragging && !reduceMotion ? 1.2 : 1 }}
        />
        {/* the bubble pops up while dragging and leans into the direction of travel */}
        <motion.div
          aria-hidden
          className="pointer-events-none absolute bottom-[calc(50%+18px)] -translate-x-1/2"
          style={{ left, rotate: reduceMotion ? 0 : smoothTilt, originY: 1 }}
          initial={false}
          animate={{ opacity: dragging ? 1 : 0, y: dragging ? 0 : 6, scale: dragging ? 1 : 0.8 }}
          transition={{ duration: 0.18 }}
        >
          <span className="block rounded-lg px-2 py-1 font-mono text-xs font-medium text-[#0C1010]" style={{ backgroundColor: color }}>
            {format(value)}
          </span>
        </motion.div>

        <input
          id={id}
          type="range"
          min={min}
          max={max}
          step={step}
          value={value}
          aria-valuetext={format(value)}
          onChange={(event) => change(Number(event.target.value))}
          onPointerDown={() => setDragging(true)}
          className="peer absolute inset-0 h-full w-full cursor-grab appearance-none opacity-0 active:cursor-grabbing"
        />
        {/* the native input is invisible, so its focus ring is drawn around the track instead */}
        <div aria-hidden className="pointer-events-none absolute -inset-x-2 inset-y-1 rounded-xl peer-focus-visible:outline-2 peer-focus-visible:outline-[#5FAEB4]" />
      </div>
    </div>
  );
}
```
