"use client";
import * as React from "react";
export type RangeValue = [number, number];
export interface DualRangeSliderProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange" | "children"> {
/** Visible label and the base of each thumb's accessible name. */
label: string;
/** Hide the label row visually (it stays available to screen readers). */
hideLabel?: boolean;
/** Current range (controlled). */
value?: RangeValue;
/** Initial range (uncontrolled). */
defaultValue?: RangeValue;
/** Called on every change of either end. */
onValueChange?: (value: RangeValue) => void;
/** Called when a drag ends, or after a keyboard change. */
onValueCommit?: (value: RangeValue) => void;
min?: number;
max?: number;
step?: number;
/** Smallest allowed gap between the two thumbs. */
minDistance?: number;
/** What happens when a thumb runs into the other one: stop there, or push it along. */
collision?: "block" | "push";
/** Optional distribution drawn above the track; bars inside the range are highlighted. */
histogram?: number[];
/** Height of the histogram in px. */
histogramHeight?: number;
/** Turns a value into text for bubbles, the readout and aria-valuetext (e.g. currency). */
formatValue?: (value: number) => string;
/** When the value bubbles above the thumbs are shown. */
bubble?: "always" | "active" | "off";
/** Show the formatted min and max under the track. */
showBounds?: boolean;
/** Fill, highlighted bars and focus colour. */
accent?: string;
disabled?: boolean;
/**
* Submits the range with a form: two hidden inputs share this name (min first),
* so `formData.getAll(name)` returns `[min, max]`.
*/
name?: string;
}
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
/** Thumb radius in px: thumbs travel inside the track so they never overhang the ends. */
const THUMB_R = 10;
function decimalsOf(step: number) {
const s = String(step);
return s.includes(".") ? s.length - s.indexOf(".") - 1 : 0;
}
const defaultFormat = (v: number) => String(v);
/**
* A two-thumb range slider with an optional histogram. Each thumb is a WAI-ARIA slider whose
* bounds follow the other thumb; the track handles pointer input with capture, so pressing
* anywhere moves the nearest thumb and keeps dragging it. Thumbs never cross: depending on
* `collision` they either stop at `minDistance` from each other or push the other thumb along.
*/
export function DualRangeSlider({
label,
hideLabel = false,
value: valueProp,
defaultValue,
onValueChange,
onValueCommit,
min = 0,
max = 100,
step = 1,
minDistance = 0,
collision = "block",
histogram,
histogramHeight = 48,
formatValue = defaultFormat,
bubble = "active",
showBounds = true,
accent = "#FF7448",
disabled = false,
name,
className = "",
style,
...props
}: DualRangeSliderProps) {
const labelId = React.useId();
const range = max - min || 1;
const gap = clamp(minDistance, 0, range);
const decimals = decimalsOf(step);
const snap = (raw: number) => {
const stepped = Math.round((raw - min) / step) * step + min;
return clamp(Number(stepped.toFixed(decimals)), min, max);
};
/** Puts a pair into a valid state: ordered, on the step grid and at least `gap` apart. */
const normalise = (pair: RangeValue): RangeValue => {
let lo = snap(Math.min(pair[0], pair[1]));
let hi = snap(Math.max(pair[0], pair[1]));
if (hi - lo < gap) {
hi = clamp(lo + gap, min, max);
lo = clamp(hi - gap, min, max);
}
return [lo, hi];
};
const [internal, setInternal] = React.useState<RangeValue>(() =>
normalise(defaultValue ?? [min + range * 0.25, min + range * 0.75]),
);
const [lo, hi] = normalise(valueProp ?? internal);
const [dragging, setDragging] = React.useState<0 | 1 | null>(null);
const [focused, setFocused] = React.useState<0 | 1 | null>(null);
const [trackWidth, setTrackWidth] = React.useState(0);
const trackRef = React.useRef<HTMLDivElement>(null);
const thumbRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const activeThumb = React.useRef<0 | 1 | null>(null);
const latest = React.useRef<RangeValue>([lo, hi]);
// The bubbles merge when they would overlap, so the track width is needed (measured, not per move).
React.useEffect(() => {
const el = trackRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => setTrackWidth(entry.contentRect.width));
ro.observe(el);
return () => ro.disconnect();
}, []);
const commit = (next: RangeValue) => {
latest.current = next;
if (next[0] === lo && next[1] === hi) return;
if (valueProp === undefined) setInternal(next);
onValueChange?.(next);
};
/** Moves one thumb to `raw`, honouring step, bounds, minDistance and the collision mode. */
const moveThumb = (index: 0 | 1, raw: number): RangeValue => {
const v = snap(raw);
if (index === 0) {
if (collision === "push") {
const nextLo = Math.min(v, max - gap);
return [nextLo, Math.max(hi, snap(nextLo + gap))];
}
return [Math.min(v, hi - gap), hi];
}
if (collision === "push") {
const nextHi = Math.max(v, min + gap);
return [Math.min(lo, snap(nextHi - gap)), nextHi];
}
return [lo, Math.max(v, lo + gap)];
};
const valueAt = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return null;
const ratio = (clientX - rect.left - THUMB_R) / Math.max(rect.width - THUMB_R * 2, 1);
return min + clamp(ratio, 0, 1) * range;
};
const endDrag = () => {
if (activeThumb.current === null) return;
activeThumb.current = null;
setDragging(null);
onValueCommit?.(latest.current);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || event.button !== 0) return;
const raw = valueAt(event.clientX);
if (raw === null) return;
event.preventDefault();
// Pressing a thumb grabs that thumb; pressing the track grabs the nearest one.
const hitThumb = thumbRefs.current.findIndex((t) => t?.contains(event.target as Node));
let index: 0 | 1;
if (hitThumb === 0 || hitThumb === 1) index = hitThumb;
else if (lo === hi) index = raw > hi ? 1 : 0;
else index = Math.abs(raw - lo) <= Math.abs(raw - hi) ? 0 : 1;
event.currentTarget.setPointerCapture(event.pointerId);
thumbRefs.current[index]?.focus({ preventScroll: true });
activeThumb.current = index;
latest.current = [lo, hi];
setDragging(index);
if (hitThumb === -1) commit(moveThumb(index, raw));
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const index = activeThumb.current;
if (index === null) return;
const raw = valueAt(event.clientX);
// commit() only re-renders when the stepped value actually changes.
if (raw !== null) commit(moveThumb(index, raw));
};
const handleKeyDown = (index: 0 | 1) => (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const current = index === 0 ? lo : hi;
const big = Math.max(step, snap(min + range / 10) - min);
let next: number;
switch (event.key) {
case "ArrowRight":
case "ArrowUp":
next = current + (event.shiftKey ? big : step);
break;
case "ArrowLeft":
case "ArrowDown":
next = current - (event.shiftKey ? big : step);
break;
case "PageUp":
next = current + big;
break;
case "PageDown":
next = current - big;
break;
case "Home":
next = min;
break;
case "End":
next = max;
break;
default:
return;
}
event.preventDefault();
const pair = moveThumb(index, next);
commit(pair);
onValueCommit?.(pair);
};
const fraction = (v: number) => (v - min) / range;
const position = (v: number) => `calc(${THUMB_R}px + (100% - ${THUMB_R * 2}px) * ${fraction(v)})`;
const loText = formatValue(lo);
const hiText = formatValue(hi);
const bubbleVisible = (index: 0 | 1) =>
bubble === "always" || (bubble === "active" && (dragging === index || focused === index));
// When the two bubbles would collide, show one combined bubble centred between the thumbs.
const usable = Math.max(trackWidth - THUMB_R * 2, 1);
const bubblesCollide = trackWidth > 0 && (fraction(hi) - fraction(lo)) * usable < 72;
const merged = bubblesCollide && (bubbleVisible(0) || bubbleVisible(1));
const bars = histogram?.length ? histogram : null;
const peak = bars ? Math.max(...bars, 0) || 1 : 1;
const thumb = (index: 0 | 1) => {
const v = index === 0 ? lo : hi;
const text = index === 0 ? loText : hiText;
// In "block" mode each thumb's bounds follow the other thumb (APG multi-thumb slider).
const blocked = collision === "block";
return (
<div className="pointer-events-none absolute top-1/2" style={{ left: position(v), zIndex: dragging === index ? 3 : 2 }}>
{!merged && (
<div
aria-hidden="true"
className={`absolute bottom-full left-0 mb-4 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 font-mono text-[11px] tabular-nums text-white shadow-sm transition-[opacity,translate] duration-150 motion-reduce:transition-none dark:bg-white dark:text-zinc-900 ${
bubbleVisible(index) ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"
}`}
>
{text}
</div>
)}
<div
ref={(el) => {
thumbRefs.current[index] = el;
}}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`${label}, ${index === 0 ? "minimum" : "maximum"}`}
aria-valuemin={index === 0 ? min : blocked ? lo + gap : min + gap}
aria-valuemax={index === 1 ? max : blocked ? hi - gap : max - gap}
aria-valuenow={v}
aria-valuetext={text}
aria-orientation="horizontal"
aria-disabled={disabled || undefined}
onKeyDown={handleKeyDown(index)}
onFocus={(event) => setFocused(event.currentTarget.matches(":focus-visible") ? index : null)}
onBlur={() => setFocused(null)}
className={`pointer-events-auto absolute left-0 top-0 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-zinc-900/15 bg-white shadow-[0_1px_3px_rgb(0_0_0/0.18)] outline-none ring-[var(--drs-accent)] ring-offset-2 ring-offset-white transition-transform duration-150 focus-visible:ring-2 motion-reduce:transition-none dark:border-white/20 dark:bg-zinc-100 dark:ring-offset-[#131619] ${
dragging === index ? "scale-110" : ""
} ${disabled ? "cursor-not-allowed" : "cursor-grab active:cursor-grabbing"}`}
>
<span aria-hidden="true" className="absolute inset-[6px] rounded-full bg-[var(--drs-accent)]" />
</div>
</div>
);
};
return (
<div
role="group"
aria-labelledby={labelId}
className={`flex w-full select-none flex-col gap-2 ${disabled ? "opacity-50" : ""} ${className}`}
style={{ ["--drs-accent" as string]: accent, ...style }}
{...props}
>
<div className={hideLabel ? "sr-only" : "flex items-baseline justify-between gap-3"}>
<span id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</span>
<span aria-hidden="true" className="font-mono text-xs tabular-nums text-zinc-900 dark:text-zinc-100">
{loText} – {hiText}
</span>
</div>
{bars && (
// Decorative: the distribution is not exposed to assistive tech; the thumbs carry the values.
<div aria-hidden="true" className="flex items-end gap-px" style={{ height: histogramHeight, paddingInline: THUMB_R }}>
{bars.map((count, i) => {
const centre = min + ((i + 0.5) / bars.length) * range;
const inside = centre >= lo && centre <= hi;
return (
<span
key={i}
className={`min-w-0 flex-1 rounded-t-[2px] transition-[background-color] duration-200 motion-reduce:transition-none ${
inside ? "bg-[var(--drs-accent)]" : "bg-zinc-900/[0.12] dark:bg-white/[0.12]"
}`}
style={{ height: `${Math.max((Math.max(count, 0) / peak) * 100, count > 0 ? 4 : 0)}%` }}
/>
);
})}
</div>
)}
<div
ref={trackRef}
className={`relative h-8 touch-pan-y ${bars ? "-mt-2" : bubble === "off" ? "" : "mt-7"} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
>
<div aria-hidden="true" className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-zinc-900/10 dark:bg-white/10">
<div
className="absolute inset-y-0 rounded-full bg-[var(--drs-accent)]"
style={{ left: position(lo), right: `calc(100% - ${position(hi)})` }}
/>
</div>
{merged && (
<div className="pointer-events-none absolute top-1/2" style={{ left: `calc((${position(lo)} + ${position(hi)}) / 2)`, zIndex: 4 }}>
<div
aria-hidden="true"
className="absolute bottom-full left-0 mb-4 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 font-mono text-[11px] tabular-nums text-white shadow-sm dark:bg-white dark:text-zinc-900"
>
{lo === hi ? loText : `${loText} – ${hiText}`}
</div>
</div>
)}
{thumb(0)}
{thumb(1)}
</div>
{showBounds && (
<div aria-hidden="true" className="-mt-1 flex justify-between font-mono text-[10px] tabular-nums tracking-[0.06em] text-zinc-500 dark:text-zinc-400">
<span>{formatValue(min)}</span>
<span>{formatValue(max)}</span>
</div>
)}
{name !== undefined && (
<>
<input type="hidden" name={name} value={lo} />
<input type="hidden" name={name} value={hi} />
</>
)}
</div>
);
}