Draggable Range Slider
A refined range slider, single or dual thumb: a value bubble that follows the handle and swells while dragging, tick marks with labels, a histogram behind the track that lights up inside the selection, step snapping with a small haptic-style bump, and full keyboard and touch support. No dependencies.
DOM + CSS0 deps1 file · 26 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
export type RangeSliderProps = {
min?: number;
max?: number;
/** Single mode: the value. Dual mode: the lower thumb. */
defaultValue?: number;
/** Dual mode only: the upper thumb (defaults to 75% of the way along). */
defaultEnd?: number;
/** Two thumbs and a lit range between them. */
dual?: boolean;
step?: number;
/** Histogram drawn behind the track; the selected range lights up. */
data?: number[];
/** Number of labelled divisions under the track (0 = no marks). */
ticks?: number;
/** "active": bubble on drag, hover and keyboard focus. "always": a quiet bubble at rest. */
bubble?: "active" | "always";
/** Text for the bubble and aria-valuetext. */
format?: (value: number) => string;
/** Text for the tick labels (falls back to format). */
tickFormat?: (value: number) => string;
onChange?: (value: number | [number, number]) => void;
disabled?: boolean;
label?: string;
/** Accent: #hex, rgb() or hsl(). */
color?: string;
/** The root is w-full and capped by --rs-max (20rem); override with className, e.g. [--rs-max:36rem]. */
className?: string;
};
const RS_COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([0-9\s.,%/-]+\))$/i;
const RS_THUMB = 26;
const RS_ROW = 44;
const RS_TICKS_H = 28;
const rsClamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
const rsAt = <T,>(a: readonly T[], i: number): T => a[i] as T;
const rsDecimals = (s: number) => {
const t = String(s);
const i = t.indexOf(".");
return i < 0 ? 0 : Math.min(6, t.length - i - 1);
};
type Sp = { x: number; v: number };
const rsSp = (x = 0): Sp => ({ x, v: 0 });
/** Semi-implicit Euler spring, 120 Hz substeps. Returns true while it is still moving. */
function rsStep(s: Sp, target: number, k: number, c: number, dt: number, still: boolean, eps: number): boolean {
if (still) {
s.x = target;
s.v = 0;
return false;
}
const n = Math.max(1, Math.ceil(dt * 120));
const h = dt / n;
for (let i = 0; i < n; i++) {
s.v += (k * (target - s.x) - c * s.v) * h;
s.x += s.v * h;
}
return Math.abs(target - s.x) > eps || Math.abs(s.v) > eps * 20;
}
function rsSubscribe(cb: () => void) {
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
}
function useRsReduced(): boolean {
return useSyncExternalStore(
rsSubscribe,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const RS_CSS = [
".rs-bar{transform-origin:50% 100%;transition:transform .8s cubic-bezier(.2,.8,.2,1);transition-delay:var(--rs-d,0ms)}",
"[data-rs-arm]:not([data-rs-live]) .rs-bar{transform:scaleY(0)}",
"@media (prefers-reduced-motion:reduce){.rs-bar{transition:none}}",
].join("\n");
type Th = { pos: Sp; sc: Sp; halo: Sp; vis: Sp; infl: Sp; tilt: Sp };
const rsThumbSim = (r: number): Th => ({ pos: rsSp(r), sc: rsSp(1), halo: rsSp(0), vis: rsSp(0), infl: rsSp(0), tilt: rsSp(0) });
export function RangeSlider({
min = 0,
max = 100,
defaultValue = 40,
defaultEnd,
dual = false,
step = 1,
data,
ticks = 0,
bubble = "active",
format,
tickFormat,
onChange,
disabled = false,
label = "Value",
color = "#ef4444",
className,
}: RangeSliderProps) {
const span = Math.max(max - min, 1e-9);
const stp = step > 0 ? step : 1;
const dec = rsDecimals(stp);
const accent = RS_COLOR.test(color) ? color : "#ef4444";
const reduced = useRsReduced();
const fmt = format ?? ((v: number) => String(v));
const tfmt = tickFormat ?? fmt;
const snap = (raw: number) =>
rsClamp(Number((min + Math.round((raw - min) / stp) * stp).toFixed(dec)), min, max);
const series = data && data.length > 1 ? data.slice(0, 80).map((d) => (Number.isFinite(d) && d > 0 ? d : 0)) : [];
const nBars = series.length;
const hasData = nBars > 0;
const seriesMax = Math.max(...series, 1e-9);
const nTicks = rsClamp(Math.round(ticks), 0, 20);
const sub = 4;
const nMarks = nTicks > 0 ? nTicks * sub + 1 : 0;
const [vals, setVals] = useState<[number, number]>(() => {
const a = snap(defaultValue);
if (!dual) return [min, a];
const b = snap(defaultEnd ?? min + span * 0.75);
return [Math.min(a, b), Math.max(a, b)];
});
const lo = dual ? rsClamp(vals[0], min, max) : min;
const hi = rsClamp(vals[1], min, max);
const valsRef = useRef<[number, number]>([lo, hi]);
const live = useRef({ drag: -1, hover: false, focus: -1, kick: [0, 0], touch: false, grab: 0 });
const wakeRef = useRef<(() => void) | null>(null);
const cfgRef = useRef({ min, span, dual, reduced, always: bubble === "always", n: nBars, marks: nMarks, accent, merged: false, hist: hasData });
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const thumbEls = useRef<(HTMLDivElement | null)[]>([null, null]);
const haloEls = useRef<(HTMLSpanElement | null)[]>([null, null]);
const bubEls = useRef<(HTMLDivElement | null)[]>([null, null]);
const guideEls = useRef<(HTMLSpanElement | null)[]>([null, null]);
const barEls = useRef<(HTMLDivElement | null)[]>([]);
const barLit = useRef<(HTMLElement | null)[]>([]);
const tickLine = useRef<(HTMLElement | null)[]>([]);
const tickText = useRef<(HTMLElement | null)[]>([]);
const [top, setTop] = useState(1);
const [w, setW] = useState(0);
// Initial paint values. They never change after mount, so React never rewrites what the loop owns.
const [init] = useState(() => {
const r = (v: number) => (v - min) / span;
const a = dual ? r(lo) : 0;
const b = r(hi);
const always = bubble === "always";
const cov: number[] = [];
for (let j = 0; j < nBars; j++) {
const s = j / nBars;
const e = (j + 1) / nBars;
cov.push(rsClamp((Math.min(b, e) - Math.max(a, s)) * nBars, 0, 1));
}
return {
vars: { "--rs-a": a.toFixed(5), "--rs-b": b.toFixed(5) } as Record<string, string>,
bubA: { left: (r(lo) * 100).toFixed(3) + "%", opacity: always ? 0.78 : 0, transform: "translate(-50%,-100%) scale(" + (always ? 0.86 : 0.55) + ")" },
bubB: { left: (r(hi) * 100).toFixed(3) + "%", opacity: always ? 0.78 : 0, transform: "translate(-50%,-100%) scale(" + (always ? 0.86 : 0.55) + ")" },
guideA: (r(lo) * 100).toFixed(3) + "%",
guideB: (r(hi) * 100).toFixed(3) + "%",
cov,
};
});
const merged = dual && w > 0 && ((hi - lo) / span) * w < 58;
useEffect(() => {
valsRef.current = [lo, hi];
cfgRef.current = { min, span, dual, reduced, always: bubble === "always", n: nBars, marks: nMarks, accent, merged, hist: hasData };
wakeRef.current?.();
}, [lo, hi, min, span, dual, reduced, bubble, nBars, nMarks, accent, merged, hasData]);
// Width, for merging the two bubbles when the thumbs get close.
useEffect(() => {
const zone = zoneRef.current;
if (!zone) return;
const ro = new ResizeObserver(() => setW(zone.offsetWidth));
ro.observe(zone);
return () => ro.disconnect();
}, []);
// Histogram reveal: bars rise the first time the slider scrolls into view.
useEffect(() => {
const root = rootRef.current;
if (!root || !hasData || reduced) return;
root.setAttribute("data-rs-arm", "");
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
root.setAttribute("data-rs-live", "");
io.disconnect();
}
},
{ threshold: 0.2 },
);
io.observe(root);
return () => {
io.disconnect();
root.removeAttribute("data-rs-arm");
root.removeAttribute("data-rs-live");
};
}, [hasData, reduced]);
// The motion loop. Springs live here; it writes the DOM directly and sleeps when everything is at rest.
useEffect(() => {
const zone = zoneRef.current;
if (!zone) return;
const c0 = cfgRef.current;
const v0 = valsRef.current;
const th: Th[] = [rsThumbSim((v0[0] - c0.min) / c0.span), rsThumbSim((v0[1] - c0.min) / c0.span)];
const mrg = rsSp(0);
const lastIn: boolean[] = [];
let raf = 0;
let last = 0;
const frame = (t: number) => {
raf = 0;
const dt = last ? Math.min(0.05, (t - last) / 1000) : 1 / 60;
last = t;
const c = cfgRef.current;
const L = live.current;
const vv = valsRef.current;
const still = c.reduced;
const rt = (v: number) => (v - c.min) / c.span;
let mv = false;
for (let i = 0; i < 2; i++) {
const s = rsAt(th, i);
const dragI = L.drag === i;
const focI = L.focus === i;
const k = L.kick[i] ?? 0;
if (k && !still) {
s.sc.v += k;
L.kick[i] = 0;
}
mv = rsStep(s.pos, rt(rsAt(vv, i)), 620, 36, dt, still, 1e-5) || mv;
mv = rsStep(s.sc, still ? 1 : dragI ? 1.2 : L.hover ? 1.1 : 1, 380, 17, dt, still, 1e-3) || mv;
mv = rsStep(s.halo, dragI ? 1 : focI ? 0.7 : 0, 260, 26, dt, still, 1e-3) || mv;
mv = rsStep(s.vis, dragI || focI ? 1 : L.hover ? 0.6 : 0, 300, 26, dt, still, 1e-3) || mv;
mv = rsStep(s.infl, dragI ? 1 : focI ? 0.5 : 0, 160, 20, dt, still, 1e-3) || mv;
mv = rsStep(s.tilt, still ? 0 : rsClamp(s.pos.v * 7, -10, 10), 260, 20, dt, still, 1e-2) || mv;
}
mv = rsStep(mrg, c.merged ? 1 : 0, 300, 28, dt, still, 1e-3) || mv;
const t0 = rsAt(th, 0);
const t1 = rsAt(th, 1);
const a = c.dual ? t0.pos.x : 0;
const b = t1.pos.x;
zone.style.setProperty("--rs-a", a.toFixed(5));
zone.style.setProperty("--rs-b", b.toFixed(5));
const mid = (a + b) / 2;
for (let i = 0; i < 2; i++) {
const s = rsAt(th, i);
const thumb = thumbEls.current[i];
if (thumb) thumb.style.transform = "translate(-50%,-50%) scale(" + s.sc.x.toFixed(4) + ")";
const halo = haloEls.current[i];
if (halo) {
halo.style.opacity = Math.max(0, s.halo.x).toFixed(3);
halo.style.transform = "translate(-50%,-50%) scale(" + (0.6 + 0.55 * s.halo.x).toFixed(4) + ")";
}
const guide = guideEls.current[i];
if (guide) {
guide.style.left = (s.pos.x * 100).toFixed(3) + "%";
guide.style.opacity = (Math.max(0, s.halo.x) * 0.9).toFixed(3);
}
const bub = bubEls.current[i];
if (bub) {
const m = c.dual ? mrg.x : 0;
const x = s.pos.x + (mid - s.pos.x) * m;
const base = c.always ? 0.78 : 0;
const bs = c.always ? 0.86 : 0.55;
const vis = rsClamp(s.vis.x, 0, 1.1);
let op = base + (1 - base) * vis;
if (i === 1 && c.dual) op *= 1 - rsClamp(m, 0, 1);
const sc = bs + (1 - bs) * vis + 0.1 * s.halo.x;
bub.style.left = (x * 100).toFixed(3) + "%";
bub.style.opacity = rsClamp(op, 0, 1).toFixed(3);
bub.style.transform =
"translate(-50%,-100%) translateY(" + ((1 - vis) * 5).toFixed(2) + "px) rotate(" + s.tilt.x.toFixed(2) + "deg) scale(" + sc.toFixed(4) + ")";
}
}
const n = c.n;
if (n > 0) {
for (let j = 0; j < n; j++) {
const sj = j / n;
const ej = (j + 1) / n;
const cj = (j + 0.5) / n;
const lit = barLit.current[j];
if (lit) lit.style.opacity = rsClamp((Math.min(b, ej) - Math.max(a, sj)) * n, 0, 1).toFixed(3);
const bar = barEls.current[j];
if (bar) {
let wv = 0;
for (let i = 0; i < 2; i++) {
if (i === 0 && !c.dual) continue;
const s = rsAt(th, i);
const d = (cj - s.pos.x) / 0.1;
wv = Math.max(wv, s.infl.x * Math.exp(-d * d));
}
bar.style.transform = still ? "" : "scaleY(" + (1 + 0.28 * wv).toFixed(4) + ")";
}
}
}
const nm = c.marks;
if (nm > 1) {
for (let j = 0; j < nm; j++) {
const p = j / (nm - 1);
const inside = p >= a - 1e-4 && p <= b + 1e-4;
let near = 0;
for (let i = 0; i < 2; i++) {
if (i === 0 && !c.dual) continue;
const s = rsAt(th, i);
const d = (p - s.pos.x) / 0.07;
near = Math.max(near, Math.exp(-d * d) * (0.35 + 0.65 * s.infl.x));
}
const major = j % 4 === 0;
const line = tickLine.current[j];
if (line) {
line.style.opacity = ((inside ? 0.8 : 0.3) + (major ? 0.1 : 0) + 0.5 * near).toFixed(3);
line.style.transform = still ? "" : "scaleY(" + (1 + 0.7 * near).toFixed(3) + ")";
if (lastIn[j] !== inside) {
lastIn[j] = inside;
line.style.backgroundColor = inside ? c.accent : "";
}
}
const text = tickText.current[j];
if (text) text.style.opacity = (0.45 + 0.5 * near).toFixed(3);
}
}
if (mv) raf = requestAnimationFrame(frame);
else last = 0;
};
const wake = () => {
if (!raf) raf = requestAnimationFrame(frame);
};
wakeRef.current = wake;
wake();
return () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
wakeRef.current = null;
};
}, []);
// ---- input ----
const setThumb = (i: number, raw: number) => {
const cur = valsRef.current;
const idx = dual ? i : 1;
let v = snap(raw);
if (dual) v = idx === 0 ? Math.min(v, cur[1]) : Math.max(v, cur[0]);
if (v === cur[idx]) return;
const next: [number, number] = idx === 0 ? [v, cur[1]] : [cur[0], v];
valsRef.current = next;
setVals(next);
const pxStep = ((zoneRef.current?.offsetWidth ?? 300) * stp) / span;
live.current.kick[idx] = (live.current.kick[idx] ?? 0) + rsClamp(pxStep * 0.35, 0.25, 2.4);
if (live.current.touch && pxStep >= 6 && typeof navigator.vibrate === "function") navigator.vibrate(3);
wakeRef.current?.();
onChange?.(dual ? next : next[1]);
};
const ratioAt = (clientX: number) => {
const zone = zoneRef.current;
if (!zone) return 0;
const rect = zone.getBoundingClientRect();
return rsClamp((clientX - rect.left) / Math.max(1, rect.width), 0, 1);
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
const zone = zoneRef.current;
if (!zone) return;
const r = ratioAt(e.clientX);
const cur = valsRef.current;
const ra = (cur[0] - min) / span;
const rb = (cur[1] - min) / span;
let idx = 1;
if (dual) {
const da = Math.abs(ra - r);
const db = Math.abs(rb - r);
idx = da < db ? 0 : da > db ? 1 : r < ra ? 0 : 1;
}
const rt = idx === 0 ? ra : rb;
const grab = Math.abs(rt - r) * zone.offsetWidth <= RS_THUMB / 2 + 2 ? rt - r : 0;
const L = live.current;
L.drag = idx;
L.grab = grab;
L.touch = e.pointerType !== "mouse";
setTop(idx);
try {
zone.setPointerCapture(e.pointerId);
} catch {
/* capture is a nicety */
}
thumbEls.current[idx]?.focus({ preventScroll: true });
setThumb(idx, min + rsClamp(r + grab, 0, 1) * span);
wakeRef.current?.();
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const L = live.current;
if (L.drag >= 0) {
setThumb(L.drag, min + rsClamp(ratioAt(e.clientX) + L.grab, 0, 1) * span);
} else if (e.pointerType === "mouse" && !disabled && !L.hover) {
L.hover = true;
wakeRef.current?.();
}
};
const endDrag = () => {
const L = live.current;
if (L.drag < 0) return;
L.drag = -1;
wakeRef.current?.();
};
const onLeave = () => {
live.current.hover = false;
wakeRef.current?.();
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>, i: number) => {
if (disabled) return;
const cur = valsRef.current;
const idx = dual ? i : 1;
const v = cur[idx];
const big = Math.max(stp, Math.round(span / 10));
let target: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
target = v + (e.shiftKey ? big : stp);
break;
case "ArrowLeft":
case "ArrowDown":
target = v - (e.shiftKey ? big : stp);
break;
case "PageUp":
target = v + big;
break;
case "PageDown":
target = v - big;
break;
case "Home":
target = idx === 1 && dual ? cur[0] : min;
break;
case "End":
target = idx === 0 ? cur[1] : max;
break;
default:
return;
}
e.preventDefault();
live.current.touch = false;
setThumb(idx, target);
};
const onFocusThumb = (i: number) => {
let vis = true;
try {
vis = !!thumbEls.current[i]?.matches(":focus-visible");
} catch {
/* older engines: treat as visible */
}
live.current.focus = vis ? i : -1;
setTop(i);
wakeRef.current?.();
};
const onBlurThumb = () => {
live.current.focus = -1;
wakeRef.current?.();
};
// ---- layout ----
const histH = "var(--rs-h)";
const rowTop = "calc(" + histH + " + " + RS_ROW / 2 + "px)";
const zoneStyle = {
...init.vars,
"--rs-h": hasData ? "var(--rs-hist, 56px)" : "0px",
height: "calc(" + histH + " + " + (RS_ROW + (nTicks > 0 ? RS_TICKS_H : 0)) + "px)",
touchAction: "pan-y",
WebkitTapHighlightColor: "transparent",
} as CSSProperties;
const thumbBg = "linear-gradient(180deg,#ffffff,#e9ebf0)";
const thumbShadow =
"inset 0 0 0 2px " + accent + ", 0 1px 2px rgba(0,0,0,.35), 0 8px 16px -6px color-mix(in srgb, " + accent + " 60%, transparent)";
const bubbleText = (i: number) => (i === 0 ? (merged ? fmt(lo) + " – " + fmt(hi) : fmt(lo)) : fmt(hi));
const idxs = dual ? [0, 1] : [1];
const marks: { p: number; major: boolean; text: string }[] = [];
for (let j = 0; j < nMarks; j++) {
const p = j / (nMarks - 1);
marks.push({ p, major: j % sub === 0, text: tfmt(Number((min + p * span).toFixed(Math.max(dec, 2)))) });
}
return (
<div
ref={rootRef}
role={dual ? "group" : undefined}
aria-label={dual ? label : undefined}
aria-disabled={disabled || undefined}
className={"relative w-full select-none px-3.5 pb-5 pt-12 " + (disabled ? "opacity-50 " : "") + (className ?? "")}
style={{ maxWidth: "var(--rs-max, 20rem)" }}
>
<style>{RS_CSS}</style>
<div
ref={zoneRef}
className={"relative w-full " + (disabled ? "cursor-not-allowed" : "cursor-pointer")}
style={zoneStyle}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
onPointerLeave={onLeave}
>
{hasData && (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 flex items-end gap-[2px]" style={{ height: histH }}>
{series.map((d, j) => (
<div
key={j}
ref={(el) => {
barEls.current[j] = el;
}}
className="flex h-full min-w-0 flex-1 items-end"
style={{ transformOrigin: "50% 100%" }}
>
<span
className="rs-bar relative block w-full overflow-hidden rounded-t-[3px]"
style={{
height: Math.max(7, (d / seriesMax) * 78).toFixed(2) + "%",
background: "rgba(127,127,138,.3)",
["--rs-d" as string]: j * 14 + "ms",
}}
>
<i
ref={(el) => {
barLit.current[j] = el;
}}
className="absolute inset-0 block"
style={{
background: "linear-gradient(180deg,color-mix(in srgb, " + accent + " 78%, #fff)," + accent + ")",
opacity: init.cov[j] ?? 0,
}}
/>
</span>
</div>
))}
</div>
)}
{/* guides: a hairline from the active thumb up through the histogram */}
{hasData &&
idxs.map((i) => (
<span
key={"g" + i}
ref={(el) => {
guideEls.current[i] = el;
}}
aria-hidden
className="pointer-events-none absolute w-px"
style={{
left: i === 0 ? init.guideA : init.guideB,
top: 0,
height: rowTop,
opacity: 0,
background: "linear-gradient(180deg, transparent, " + accent + ")",
}}
/>
))}
{/* track */}
<div
aria-hidden
className="absolute inset-x-0 h-1.5 rounded-full"
style={{ top: "calc(" + rowTop + " - 3px)", background: "rgba(127,127,138,.28)", boxShadow: "inset 0 1px 1px rgba(0,0,0,.18)" }}
>
<div
className="absolute inset-y-0 rounded-full"
style={{
left: "calc(var(--rs-a) * 100%)",
width: "calc((var(--rs-b) - var(--rs-a)) * 100%)",
background: "linear-gradient(90deg,color-mix(in srgb, " + accent + " 78%, #fff)," + accent + ")",
boxShadow: "0 0 14px -2px color-mix(in srgb, " + accent + " 55%, transparent)",
}}
/>
</div>
{/* ticks */}
{nMarks > 0 && (
<div aria-hidden className="pointer-events-none absolute inset-x-0" style={{ top: "calc(" + histH + " + " + (RS_ROW - 2) + "px)" }}>
{marks.map((m, j) => (
<div key={j} className="absolute top-0" style={{ left: m.p * 100 + "%" }}>
<i
ref={(el) => {
tickLine.current[j] = el;
}}
className="absolute left-0 top-0 block w-px"
style={{ height: m.major ? 9 : 5, transformOrigin: "50% 0", background: "currentColor", opacity: 0.3, marginLeft: -0.5 }}
/>
{m.major && (
<span
ref={(el) => {
tickText.current[j] = el;
}}
className="absolute left-0 top-[13px] -translate-x-1/2 whitespace-nowrap text-[11px] leading-none tabular-nums"
style={{ opacity: 0.45 }}
>
{m.text}
</span>
)}
</div>
))}
</div>
)}
{/* halos */}
{idxs.map((i) => (
<span
key={"h" + i}
ref={(el) => {
haloEls.current[i] = el;
}}
aria-hidden
className="pointer-events-none absolute rounded-full"
style={{
left: "calc(var(" + (i === 0 ? "--rs-a" : "--rs-b") + ") * 100%)",
top: rowTop,
width: 48,
height: 48,
opacity: 0,
transform: "translate(-50%,-50%) scale(.6)",
background: "radial-gradient(closest-side, color-mix(in srgb, " + accent + " 34%, transparent), transparent)",
}}
/>
))}
{/* thumbs */}
{idxs.map((i) => {
const v = i === 0 ? lo : hi;
return (
<div
key={"t" + i}
ref={(el) => {
thumbEls.current[i] = el;
}}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-orientation="horizontal"
aria-valuemin={dual && i === 1 ? lo : min}
aria-valuemax={dual && i === 0 ? hi : max}
aria-valuenow={v}
aria-valuetext={fmt(v)}
aria-disabled={disabled || undefined}
aria-label={dual ? label + (i === 0 ? " minimum" : " maximum") : label}
onKeyDown={(e) => onKeyDown(e, i)}
onFocus={() => onFocusThumb(i)}
onBlur={onBlurThumb}
className="absolute grid place-items-center rounded-full outline-none focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-[3px] focus-visible:![outline-color:var(--rs-ring)]"
style={{
["--rs-ring" as string]: accent,
left: "calc(var(" + (i === 0 ? "--rs-a" : "--rs-b") + ") * 100%)",
top: rowTop,
width: RS_THUMB,
height: RS_THUMB,
zIndex: top === i ? 20 : 10,
transform: "translate(-50%,-50%)",
background: thumbBg,
boxShadow: thumbShadow,
}}
>
<span aria-hidden className="block h-[7px] w-[7px] rounded-full" style={{ background: accent, opacity: 0.9 }} />
</div>
);
})}
{/* value bubbles */}
{idxs.map((i) => {
const b = i === 0 ? init.bubA : init.bubB;
return (
<div
key={"b" + i}
ref={(el) => {
bubEls.current[i] = el;
}}
aria-hidden
className="pointer-events-none absolute z-30 whitespace-nowrap rounded-lg px-2.5 py-1 font-mono text-[12px] font-semibold leading-[18px] tabular-nums"
style={{
top: hasData ? -12 : 1,
left: b.left,
opacity: b.opacity,
transform: b.transform,
transformOrigin: "50% 100%",
background: "#111113",
color: "#fff",
boxShadow: "0 0 0 1px rgba(255,255,255,.14), 0 10px 22px -8px rgba(0,0,0,.6)",
}}
>
{bubbleText(i)}
<span
aria-hidden
className="absolute left-1/2 block h-[7px] w-[7px] rotate-45"
style={{ bottom: -3, marginLeft: -3.5, background: "#111113", boxShadow: "1px 1px 0 rgba(255,255,255,.1)" }}
/>
</div>
);
})}
</div>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
A custom track instead of a native range input, so every part can be styled. Values live in plain state (one number, or a low and high pair in dual mode) and snap to the step prop. A small spring loop, written by hand with no animation library, glides each handle to its snapped value, swells it while it is held, and gives it a tiny scale kick every time it crosses a step. The value bubble follows the handle, tilts with its speed and, with two handles close together, merges into one bubble that reads the whole range. Pass a data array and a histogram rises behind the track, with the bars inside the selection lit in the accent and the bars near the held handle lifting like a wave. The ticks prop adds hairline marks with labels that brighten as a handle passes. Press anywhere on the track to jump the nearest handle there; grab a handle directly and it keeps your offset. Touch drags horizontally while the page still scrolls vertically, and a coarse step buzzes lightly on phones that support it. It paints its own surfaces with a neutral track, an accent fill and a white handle, so it sits on a light or a dark page unchanged. The root is full width up to 20rem; override the cap with a className such as [--rs-max:36rem], and the histogram height with [--rs-hist:96px].
Curator’s note
Rebuilt in October as a proper product slider: one or two handles, a histogram that lights up inside the selection, and a value bubble that leans with the drag. The handles still read from a single value, so the fill, the bars and the thumbs cannot drift apart.

