Draggable Range Slider
A custom-styled slider with a drag handle and a value tooltip while dragging.
- Category
- Components
- Added
- 2026-08-22
- Deps
- none
- Updated
- 2026-09-01
Props
"use client";
import { useEffect, useRef, useState } from "react";
export function RangeSlider({ min = 0, max = 100, defaultValue = 40 }: {
min?: number;
max?: number;
defaultValue?: number;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [value, setValue] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const percent = ((value - min) / (max - min)) * 100;
const step = 1;
const bigStep = Math.max(step, Math.round((max - min) / 10));
function clamp(v: number) {
return Math.min(max, Math.max(min, v));
}
function updateFromClientX(clientX: number) {
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
setValue(Math.round(min + ratio * (max - min)));
}
function handleKeyDown(e: React.KeyboardEvent) {
let delta = 0;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
delta = e.shiftKey ? bigStep : step;
break;
case "ArrowLeft":
case "ArrowDown":
delta = -(e.shiftKey ? bigStep : step);
break;
case "PageUp":
delta = bigStep;
break;
case "PageDown":
delta = -bigStep;
break;
case "Home":
e.preventDefault();
setValue(min);
return;
case "End":
e.preventDefault();
setValue(max);
return;
default:
return;
}
e.preventDefault();
setValue((v) => clamp(v + delta));
}
useEffect(() => {
if (!dragging) return;
function handleMove(e: PointerEvent) { updateFromClientX(e.clientX); }
function handleUp() { setDragging(false); }
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
return () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
};
}, [dragging]);
return (
<div className="w-full max-w-xs px-2 py-6">
<div
ref={trackRef}
className="relative h-1.5 w-full cursor-pointer rounded-full bg-neutral-200"
onPointerDown={(e) => { updateFromClientX(e.clientX); setDragging(true); }}
>
<div className="absolute inset-y-0 left-0 rounded-full bg-red-500" style={{ width: `${percent}%` }} />
<div
role="slider"
tabIndex={0}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-label="Value"
onKeyDown={handleKeyDown}
className="absolute top-1/2 flex h-5 w-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 border-red-500 bg-white shadow-md outline-none focus-visible:ring-2 focus-visible:ring-red-500"
style={{ left: `${percent}%` }}
>
{dragging && (
<span className="absolute -top-9 rounded-md bg-neutral-900 px-2 py-1 font-mono text-xs font-bold text-white">
{value}
</span>
)}
</div>
</div>
</div>
);
}
About this component
Restyling a native input of type range hits a wall fast; cross-browser thumb and track styling is inconsistent enough that most teams rebuild the whole thing, which is what this does. Position comes from a single piece of state, a percent derived from value, min, and max, that both the fill bar and the thumb read from, so they cannot visually desync the way they can when a drag library's own transform state gets layered on top of separately computed position state, a mistake the author's note calls out as something an earlier version got wrong. Dragging is wired with raw pointer events: pointerdown starts a dragging flag, a useEffect that only runs while dragging attaches window-level pointermove and pointerup listeners, and clientX maps back to a value through the track's bounding rect on every move. The thumb also carries role="slider", tabIndex, and aria-valuemin/max/now, with arrow keys stepping the value by 1 (Shift+Arrow or PageUp/PageDown by a larger step, Home/End jumping to the ends), so it is fully operable from the keyboard, not just pointer and touch.
Curator’s note
Position comes from one piece of state, read by both the fill bar and the thumb — an early version tried mixing this with Framer Motion's own drag transform for a nicer feel, and the two position sources fought each other and drifted apart on every drag. Plain pointer events kept it honest.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Image Comparison Slider
componentsA draggable before/after divider that reveals one image over another.
Scrub Number Field
componentsA numeric input whose label you drag to scrub, with pointer lock and precision modifiers.
Floating Label Input
componentsA text input whose label floats from placeholder position into a caption on focus.