ui
FreeSpringSlider
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.