A range slider with give: the value bubble leans as you drag, and pulling past the ends stretches the track like rubber.
Customize
Usage
import { ElasticSlider } from "@/components/motionkit/elastic-slider";
<ElasticSlider
label="Volume"
defaultValue={60}
format={(value) => `${value}%`}
onValueChange={setVolume}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Thumb follow
Value changes
Track stretch
Dragged past an end
Volume, price ranges, opacity, zoom and any settings value, where a slider should feel physical rather than flat.
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.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://motionkitui.com/r/elastic-slider.jsonnpm install motionSave as components/motionkit/elastic-slider.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>
);
}
| 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. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Only the motion package, beside React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://motionkitui.com/r/elastic-slider.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, the track never stretches, the bubble stays upright and the thumb doesn't grow.
Liquid Toggle
A switch whose knob stretches like liquid on its way across, then settles.
Password Strength
A password field with a four-bar strength meter and requirements that tick themselves off as you type.
Code Input
One-time-code boxes where each digit pops in, with paste and phone autofill built in.