Range Dual Slider
Original · freedual-handle min/max range
byWeb InnoventixReact + Tailwind
sldrangedualsliders
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/sld-range-dual.jsonsld-range-dual.tsx
"use client";
import {
useMemo,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Bound = "min" | "max";
const RANGE_MIN = 0;
const RANGE_MAX = 2000;
const STEP = 25;
const GAP = STEP; // handles may not overlap
const BIG_STEP = STEP * 4;
const DEFAULT_MIN = 350;
const DEFAULT_MAX = 1150;
const TOTAL_STAYS = 1842;
const BARS = 28;
const clampValue = (v: number, lo: number, hi: number): number =>
Math.min(hi, Math.max(lo, v));
const snap = (v: number): number => Math.round(v / STEP) * STEP;
const money = (v: number): string => `$${v.toLocaleString("en-US")}`;
// Deterministic bell-ish demand curve so the histogram is stable across renders.
function buildDistribution(): { bars: number[]; maxBar: number } {
const bars: number[] = [];
for (let i = 0; i < BARS; i += 1) {
const x = i / (BARS - 1);
const cheap = Math.exp(-Math.pow((x - 0.34) / 0.17, 2));
const premium = Math.exp(-Math.pow((x - 0.72) / 0.13, 2)) * 0.62;
bars.push(cheap + premium + 0.06);
}
return { bars, maxBar: Math.max(...bars) };
}
export default function SldRangeDual() {
const reduce = useReducedMotion();
const [minVal, setMinVal] = useState(DEFAULT_MIN);
const [maxVal, setMaxVal] = useState(DEFAULT_MAX);
const [dragging, setDragging] = useState<Bound | null>(null);
// While an input is focused it holds an uncommitted draft string so
// multi-digit typing isn't fought by clamping/snapping on every keystroke.
const [draft, setDraft] = useState<{ bound: Bound; text: string } | null>(null);
const draggingRef = useRef<Bound | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const minThumbRef = useRef<HTMLButtonElement>(null);
const maxThumbRef = useRef<HTMLButtonElement>(null);
const { bars, maxBar } = useMemo(buildDistribution, []);
const count = useMemo(() => {
const total = bars.reduce((sum, b) => sum + b, 0);
let selected = 0;
for (let i = 0; i < bars.length; i += 1) {
const center = RANGE_MIN + ((i + 0.5) / bars.length) * (RANGE_MAX - RANGE_MIN);
if (center >= minVal && center <= maxVal) selected += bars[i];
}
return Math.round((selected / total) * TOTAL_STAYS);
}, [bars, minVal, maxVal]);
const pct = (v: number): number =>
((v - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)) * 100;
const minPct = pct(minVal);
const maxPct = pct(maxVal);
const isDefault = minVal === DEFAULT_MIN && maxVal === DEFAULT_MAX;
const applyBound = (bound: Bound, raw: number): void => {
const v = snap(raw);
if (bound === "min") setMinVal(clampValue(v, RANGE_MIN, maxVal - GAP));
else setMaxVal(clampValue(v, minVal + GAP, RANGE_MAX));
};
const valueFromPointer = (clientX: number): number | null => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return null;
const ratio = clampValue((clientX - rect.left) / rect.width, 0, 1);
return snap(RANGE_MIN + ratio * (RANGE_MAX - RANGE_MIN));
};
const pickBound = (val: number): Bound => {
const dMin = Math.abs(val - minVal);
const dMax = Math.abs(val - maxVal);
if (dMin < dMax) return "min";
if (dMax < dMin) return "max";
return val < minVal ? "min" : "max";
};
const onTrackPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
const val = valueFromPointer(e.clientX);
if (val === null) return;
const bound = pickBound(val);
draggingRef.current = bound;
setDragging(bound);
e.currentTarget.setPointerCapture(e.pointerId);
applyBound(bound, val);
(bound === "min" ? minThumbRef : maxThumbRef).current?.focus();
};
const onTrackPointerMove = (e: ReactPointerEvent<HTMLDivElement>): void => {
if (!draggingRef.current) return;
const val = valueFromPointer(e.clientX);
if (val === null) return;
applyBound(draggingRef.current, val);
};
const endDrag = (e: ReactPointerEvent<HTMLDivElement>): void => {
if (!draggingRef.current) return;
draggingRef.current = null;
setDragging(null);
if (e.currentTarget.hasPointerCapture(e.pointerId))
e.currentTarget.releasePointerCapture(e.pointerId);
};
const onThumbKeyDown = (
e: ReactKeyboardEvent<HTMLButtonElement>,
bound: Bound,
): void => {
const cur = bound === "min" ? minVal : maxVal;
let next = cur;
let handled = true;
switch (e.key) {
case "ArrowLeft":
case "ArrowDown":
next = cur - STEP;
break;
case "ArrowRight":
case "ArrowUp":
next = cur + STEP;
break;
case "PageDown":
next = cur - BIG_STEP;
break;
case "PageUp":
next = cur + BIG_STEP;
break;
case "Home":
next = bound === "min" ? RANGE_MIN : minVal + GAP;
break;
case "End":
next = bound === "min" ? maxVal - GAP : RANGE_MAX;
break;
default:
handled = false;
}
if (!handled) return;
e.preventDefault();
applyBound(bound, next);
};
const onInputChange = (
e: ChangeEvent<HTMLInputElement>,
bound: Bound,
): void => {
setDraft({ bound, text: e.target.value });
};
const commitDraft = (): void => {
if (!draft) return;
const v = Number(draft.text);
if (draft.text.trim() !== "" && !Number.isNaN(v)) applyBound(draft.bound, v);
setDraft(null);
};
const inputValue = (bound: Bound, committed: number): string =>
draft && draft.bound === bound ? draft.text : String(committed);
const reset = (): void => {
setMinVal(DEFAULT_MIN);
setMaxVal(DEFAULT_MAX);
setDraft(null);
};
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 md:py-24 dark:bg-slate-950 dark:text-slate-100">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 [background-image:radial-gradient(circle_at_12%_-10%,rgba(99,102,241,0.12),transparent_45%),radial-gradient(circle_at_92%_110%,rgba(139,92,246,0.12),transparent_45%)]"
/>
<style>{`
@keyframes sldRangeDual_pulse {
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.45); }
70% { box-shadow: 0 0 0 9px rgba(99, 102, 241, 0); }
100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
}
.sldRangeDual_pulse { animation: sldRangeDual_pulse 1.35s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.sldRangeDual_pulse { animation: none; }
}
`}</style>
<div className="relative mx-auto max-w-xl">
<header className="mb-7">
<span className="inline-flex items-center gap-1.5 rounded-full border border-slate-300/70 bg-white/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
Search filters
</span>
<h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Set your nightly budget
</h2>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
Cabins in Asheville · Dec 12–15. Drag either handle, or
focus a handle and use the arrow keys.
</p>
</header>
<div className="rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-xl shadow-slate-900/5 backdrop-blur sm:p-7 dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30">
<div className="flex items-baseline justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
Price range
</p>
<p className="mt-1 font-mono text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
{money(minVal)}
<span className="mx-1.5 text-slate-300 dark:text-slate-600">
–
</span>
{maxVal === RANGE_MAX ? `${money(maxVal)}+` : money(maxVal)}
</p>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-400">
per night
</span>
</div>
{/* Histogram of the price distribution */}
<div
aria-hidden="true"
className="mt-6 flex h-16 items-end gap-[3px]"
>
{bars.map((h, i) => {
const center =
RANGE_MIN +
((i + 0.5) / bars.length) * (RANGE_MAX - RANGE_MIN);
const active = center >= minVal && center <= maxVal;
return (
<div
key={i}
className={`flex-1 rounded-t-[3px] transition-colors duration-200 ${
active
? "bg-indigo-500 dark:bg-indigo-400"
: "bg-slate-200 dark:bg-slate-800"
}`}
style={{ height: `${Math.max(6, (h / maxBar) * 100)}%` }}
/>
);
})}
</div>
{/* Track + dual thumbs */}
<div
ref={trackRef}
role="group"
aria-label="Nightly price range in US dollars"
onPointerDown={onTrackPointerDown}
onPointerMove={onTrackPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="relative mt-1 h-6 cursor-pointer touch-none select-none"
>
{/* Rail */}
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-slate-200 ring-1 ring-inset ring-slate-300/60 dark:bg-slate-800 dark:ring-slate-700/60" />
{/* Selected fill */}
<div
className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 dark:from-indigo-400 dark:to-violet-400"
style={{ left: `${minPct}%`, width: `${maxPct - minPct}%` }}
/>
{/* Min thumb */}
<div
className="absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2"
style={{ left: `${minPct}%` }}
>
<motion.button
ref={minThumbRef}
type="button"
role="slider"
aria-label="Minimum price"
aria-orientation="horizontal"
aria-valuemin={RANGE_MIN}
aria-valuemax={maxVal}
aria-valuenow={minVal}
aria-valuetext={`${money(minVal)} per night`}
onKeyDown={(e) => onThumbKeyDown(e, "min")}
animate={{ scale: !reduce && dragging === "min" ? 1.15 : 1 }}
transition={{ type: "spring", stiffness: 520, damping: 28 }}
className={`block h-5 w-5 cursor-grab touch-none rounded-full border-[3px] border-indigo-500 bg-white shadow-md shadow-slate-900/20 outline-none focus-visible:ring-4 focus-visible:ring-indigo-400/70 active:cursor-grabbing dark:border-indigo-400 dark:bg-slate-900 dark:shadow-black/40 dark:focus-visible:ring-indigo-300/50 ${
dragging === "min" ? "sldRangeDual_pulse" : ""
}`}
/>
</div>
{/* Max thumb */}
<div
className="absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2"
style={{ left: `${maxPct}%` }}
>
<motion.button
ref={maxThumbRef}
type="button"
role="slider"
aria-label="Maximum price"
aria-orientation="horizontal"
aria-valuemin={minVal}
aria-valuemax={RANGE_MAX}
aria-valuenow={maxVal}
aria-valuetext={`${money(maxVal)} per night`}
onKeyDown={(e) => onThumbKeyDown(e, "max")}
animate={{ scale: !reduce && dragging === "max" ? 1.15 : 1 }}
transition={{ type: "spring", stiffness: 520, damping: 28 }}
className={`block h-5 w-5 cursor-grab touch-none rounded-full border-[3px] border-violet-500 bg-white shadow-md shadow-slate-900/20 outline-none focus-visible:ring-4 focus-visible:ring-violet-400/70 active:cursor-grabbing dark:border-violet-400 dark:bg-slate-900 dark:shadow-black/40 dark:focus-visible:ring-violet-300/50 ${
dragging === "max" ? "sldRangeDual_pulse" : ""
}`}
/>
</div>
</div>
{/* Tick labels */}
<div
aria-hidden="true"
className="mt-3 flex justify-between font-mono text-[11px] font-medium tabular-nums text-slate-400 dark:text-slate-500"
>
<span>$0</span>
<span>$500</span>
<span>$1,000</span>
<span>$1,500</span>
<span>$2k+</span>
</div>
{/* Numeric inputs */}
<div className="mt-6 flex items-end gap-3">
<label className="flex-1">
<span className="mb-1.5 block text-xs font-semibold text-slate-500 dark:text-slate-400">
Minimum
</span>
<div className="flex items-center rounded-lg border border-slate-300 bg-white focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-500/30 dark:border-slate-700 dark:bg-slate-950 dark:focus-within:border-indigo-400">
<span className="pl-3 font-mono text-sm text-slate-400 dark:text-slate-500">
$
</span>
<input
type="number"
inputMode="numeric"
aria-label="Minimum price in US dollars"
min={RANGE_MIN}
max={maxVal - GAP}
step={STEP}
value={inputValue("min", minVal)}
onChange={(e) => onInputChange(e, "min")}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
className="w-full bg-transparent px-2 py-2 font-mono text-sm font-semibold tabular-nums text-slate-900 outline-none dark:text-white"
/>
</div>
</label>
<div
aria-hidden="true"
className="pb-2.5 text-slate-300 dark:text-slate-600"
>
–
</div>
<label className="flex-1">
<span className="mb-1.5 block text-xs font-semibold text-slate-500 dark:text-slate-400">
Maximum
</span>
<div className="flex items-center rounded-lg border border-slate-300 bg-white focus-within:border-violet-500 focus-within:ring-2 focus-within:ring-violet-500/30 dark:border-slate-700 dark:bg-slate-950 dark:focus-within:border-violet-400">
<span className="pl-3 font-mono text-sm text-slate-400 dark:text-slate-500">
$
</span>
<input
type="number"
inputMode="numeric"
aria-label="Maximum price in US dollars"
min={minVal + GAP}
max={RANGE_MAX}
step={STEP}
value={inputValue("max", maxVal)}
onChange={(e) => onInputChange(e, "max")}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
className="w-full bg-transparent px-2 py-2 font-mono text-sm font-semibold tabular-nums text-slate-900 outline-none dark:text-white"
/>
</div>
</label>
</div>
{/* Result summary + reset */}
<div className="mt-6 flex items-center justify-between gap-3 border-t border-slate-200/80 pt-5 dark:border-slate-800">
<p className="text-sm text-slate-600 dark:text-slate-400">
<motion.span
key={count}
initial={reduce ? false : { opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="inline-block font-mono text-base font-bold tabular-nums text-slate-900 dark:text-white"
>
{count.toLocaleString("en-US")}
</motion.span>{" "}
{count === 1 ? "stay" : "stays"} match
</p>
<button
type="button"
onClick={reset}
disabled={isDefault}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-semibold text-indigo-600 transition-colors hover:text-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:text-slate-400 dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-900 dark:disabled:text-slate-600"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v4h4" />
</svg>
Reset
</button>
</div>
{/* Live status for assistive tech */}
<span aria-live="polite" className="sr-only">
{`Price from ${money(minVal)} to ${money(maxVal)} per night. ${count.toLocaleString("en-US")} ${count === 1 ? "stay" : "stays"} match.`}
</span>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Slider
Originalbasic range slider with value bubble

Steps Slider
Originalstepped slider with tick marks

Vertical Slider
Originalvertical sliders

Tooltip Slider
Originalslider with a floating value tooltip

Volume Slider
Originalvolume slider with icon

Price Range Slider
Originalprice range filter slider

Gradient Track Slider
Originalslider with a gradient track

Rating Slider
Originalsatisfaction rating slider

Brightness Slider
Originalbrightness/contrast control sliders

Marks Slider
Originalslider with labelled marks

Color Hue Slider
Originalhue/saturation color sliders

Progress Drag Slider
Originalmedia scrubber slider

