Price Range Slider
Original · freeprice range filter slider
byWeb InnoventixReact + Tailwind
sldpricerangesliders
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-price-range.jsonsld-price-range.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
ChangeEvent,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
const DOMAIN_MIN = 0;
const DOMAIN_MAX = 1000;
const STEP = 10;
const MIN_GAP = 20;
const COUNTS = [
8, 22, 51, 96, 148, 204, 241, 268, 252, 223, 198, 176, 149, 128, 112, 97, 84,
73, 62, 55, 48, 41, 36, 31, 27, 23, 20, 17, 15, 13, 11, 9, 8, 7, 6, 5, 4, 3,
3, 6,
];
const BUCKET_SIZE = (DOMAIN_MAX - DOMAIN_MIN) / COUNTS.length;
const TOTAL = COUNTS.reduce((a, b) => a + b, 0);
const MAX_COUNT = Math.max(...COUNTS);
type Preset = { label: string; min: number; max: number };
const PRESETS: Preset[] = [
{ label: "Under $100", min: 0, max: 100 },
{ label: "$100 – $250", min: 100, max: 250 },
{ label: "$250 – $500", min: 250, max: 500 },
{ label: "$500 & up", min: 500, max: 1000 },
];
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const int = new Intl.NumberFormat("en-US");
const clamp = (v: number, lo: number, hi: number) =>
Math.min(Math.max(v, lo), hi);
const snap = (v: number) => Math.round(v / STEP) * STEP;
export default function PriceRangeSlider() {
const reduce = useReducedMotion();
const trackRef = useRef<HTMLDivElement | null>(null);
const minThumbRef = useRef<HTMLButtonElement | null>(null);
const maxThumbRef = useRef<HTMLButtonElement | null>(null);
const draggingRef = useRef<"min" | "max" | null>(null);
const [minVal, setMinVal] = useState(150);
const [maxVal, setMaxVal] = useState(450);
const [minText, setMinText] = useState("150");
const [maxText, setMaxText] = useState("450");
const [active, setActive] = useState<"min" | "max" | null>(null);
const [applied, setApplied] = useState(false);
function commitMin(n: number) {
const c = clamp(snap(n), DOMAIN_MIN, maxVal - MIN_GAP);
setMinVal(c);
setMinText(String(c));
setApplied(false);
}
function commitMax(n: number) {
const c = clamp(snap(n), minVal + MIN_GAP, DOMAIN_MAX);
setMaxVal(c);
setMaxText(String(c));
setApplied(false);
}
const pct = (v: number) =>
((v - DOMAIN_MIN) / (DOMAIN_MAX - DOMAIN_MIN)) * 100;
function valueFromClientX(clientX: number) {
const el = trackRef.current;
if (!el) return DOMAIN_MIN;
const rect = el.getBoundingClientRect();
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
return snap(DOMAIN_MIN + ratio * (DOMAIN_MAX - DOMAIN_MIN));
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
const target = e.target as HTMLElement;
const dt = target.dataset.thumb;
let which: "min" | "max";
if (dt === "min" || dt === "max") {
which = dt;
} else {
const v = valueFromClientX(e.clientX);
which = Math.abs(v - minVal) <= Math.abs(v - maxVal) ? "min" : "max";
if (which === "min") commitMin(v);
else commitMax(v);
}
draggingRef.current = which;
setActive(which);
trackRef.current?.setPointerCapture(e.pointerId);
(which === "min" ? minThumbRef : maxThumbRef).current?.focus({
preventScroll: true,
});
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
if (!draggingRef.current) return;
const v = valueFromClientX(e.clientX);
if (draggingRef.current === "min") commitMin(v);
else commitMax(v);
}
function endDrag(e: ReactPointerEvent<HTMLDivElement>) {
if (!draggingRef.current) return;
draggingRef.current = null;
setActive(null);
trackRef.current?.releasePointerCapture(e.pointerId);
}
function onThumbKey(
e: ReactKeyboardEvent<HTMLButtonElement>,
which: "min" | "max",
) {
const cur = which === "min" ? minVal : maxVal;
let next: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
next = cur + STEP;
break;
case "ArrowLeft":
case "ArrowDown":
next = cur - STEP;
break;
case "PageUp":
next = cur + STEP * 10;
break;
case "PageDown":
next = cur - STEP * 10;
break;
case "Home":
next = which === "min" ? DOMAIN_MIN : minVal + MIN_GAP;
break;
case "End":
next = which === "min" ? maxVal - MIN_GAP : DOMAIN_MAX;
break;
default:
return;
}
e.preventDefault();
if (next === null) return;
if (which === "min") commitMin(next);
else commitMax(next);
}
function onMinChange(e: ChangeEvent<HTMLInputElement>) {
setMinText(e.target.value);
}
function onMaxChange(e: ChangeEvent<HTMLInputElement>) {
setMaxText(e.target.value);
}
function commitMinText() {
const n = Number(minText.replace(/[^0-9.]/g, ""));
if (Number.isFinite(n) && minText.trim() !== "") commitMin(n);
else setMinText(String(minVal));
}
function commitMaxText() {
const n = Number(maxText.replace(/[^0-9.]/g, ""));
if (Number.isFinite(n) && maxText.trim() !== "") commitMax(n);
else setMaxText(String(maxVal));
}
function onInputKey(e: ReactKeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
}
function applyPreset(p: Preset) {
const lo = clamp(snap(p.min), DOMAIN_MIN, DOMAIN_MAX - MIN_GAP);
const hi = clamp(snap(p.max), lo + MIN_GAP, DOMAIN_MAX);
setMinVal(lo);
setMinText(String(lo));
setMaxVal(hi);
setMaxText(String(hi));
setApplied(false);
}
function reset() {
setMinVal(DOMAIN_MIN);
setMaxVal(DOMAIN_MAX);
setMinText(String(DOMAIN_MIN));
setMaxText(String(DOMAIN_MAX));
setApplied(false);
}
const isFull = minVal === DOMAIN_MIN && maxVal === DOMAIN_MAX;
const resultCount = useMemo(() => {
let sum = 0;
for (let i = 0; i < COUNTS.length; i++) {
const start = i * BUCKET_SIZE;
const end = (i + 1) * BUCKET_SIZE;
if (start < maxVal && end > minVal) sum += COUNTS[i];
}
return sum;
}, [minVal, maxVal]);
useEffect(() => {
if (!applied) return;
const t = window.setTimeout(() => setApplied(false), 2400);
return () => window.clearTimeout(t);
}, [applied]);
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes sldpr_sheen {
0% { transform: translateX(-160%); opacity: 0; }
20% { opacity: 0.85; }
60% { opacity: 0.85; }
100% { transform: translateX(520%); opacity: 0; }
}
@keyframes sldpr_rise {
from { opacity: 0; transform: translateY(14px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.sldpr-sheen { animation: sldpr_sheen 3.6s ease-in-out infinite; }
.sldpr-rise { animation: sldpr_rise 0.55s cubic-bezier(0.22,1,0.36,1) both; }
@media (prefers-reduced-motion: reduce) {
.sldpr-sheen, .sldpr-rise { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-lg">
<div className="sldpr-rise rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-900/5 sm:p-8 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600 ring-1 ring-inset ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
aria-hidden="true"
>
<path strokeLinecap="round" d="M3 7h18M3 12h18M3 17h18" />
<circle cx="8" cy="7" r="2.4" fill="currentColor" />
<circle cx="15" cy="12" r="2.4" fill="currentColor" />
<circle cx="10" cy="17" r="2.4" fill="currentColor" />
</svg>
</span>
<div>
<h3 className="text-base font-semibold tracking-tight text-slate-900 dark:text-white">
Filter by price
</h3>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
Narrow {int.format(TOTAL)} wireless headphones to your budget.
</p>
</div>
</div>
<button
type="button"
onClick={reset}
disabled={isFull}
className="shrink-0 rounded-lg px-2.5 py-1.5 text-sm font-medium text-slate-500 transition hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-400 dark:hover:text-indigo-300"
>
Reset
</button>
</div>
{/* Histogram + slider */}
<div className="mt-8">
<div
className="flex h-24 items-end gap-[3px]"
aria-hidden="true"
>
{COUNTS.map((c, i) => {
const start = i * BUCKET_SIZE;
const end = (i + 1) * BUCKET_SIZE;
const on = start < maxVal && end > minVal;
const h = Math.max(8, (c / MAX_COUNT) * 100);
return (
<motion.div
key={i}
initial={reduce ? false : { scaleY: 0.14, opacity: 0 }}
animate={{ scaleY: 1, opacity: 1 }}
transition={
reduce
? { duration: 0 }
: {
delay: Math.min(i * 0.012, 0.4),
duration: 0.45,
ease: "easeOut",
}
}
style={{ height: `${h}%`, transformOrigin: "bottom" }}
className={`flex-1 rounded-t-[3px] transition-colors duration-200 ${
on
? "bg-gradient-to-t from-indigo-500 to-violet-400"
: "bg-slate-200 dark:bg-slate-700"
}`}
/>
);
})}
</div>
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="relative mt-3 h-6 w-full touch-none select-none"
>
{/* base rail */}
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-slate-200 dark:bg-slate-700" />
{/* filled range */}
<div
className="absolute top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
style={{
left: `${pct(minVal)}%`,
width: `${pct(maxVal) - pct(minVal)}%`,
}}
>
<span
className="sldpr-sheen absolute inset-y-0 left-0 w-10 bg-white/50 blur-[2px]"
aria-hidden="true"
/>
</div>
{/* min thumb */}
<motion.button
ref={minThumbRef}
data-thumb="min"
type="button"
role="slider"
aria-label="Minimum price"
aria-valuemin={DOMAIN_MIN}
aria-valuemax={maxVal - MIN_GAP}
aria-valuenow={minVal}
aria-valuetext={usd.format(minVal)}
onKeyDown={(e) => onThumbKey(e, "min")}
whileTap={reduce ? undefined : { scale: 1.18 }}
style={{ left: `${pct(minVal)}%`, zIndex: active === "min" ? 30 : 20 }}
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none rounded-full border-2 border-indigo-500 bg-white shadow-md shadow-slate-900/25 outline-none transition-shadow active:cursor-grabbing focus-visible:ring-4 focus-visible:ring-indigo-500/30 dark:border-indigo-400 dark:bg-slate-950"
/>
{/* max thumb */}
<motion.button
ref={maxThumbRef}
data-thumb="max"
type="button"
role="slider"
aria-label="Maximum price"
aria-valuemin={minVal + MIN_GAP}
aria-valuemax={DOMAIN_MAX}
aria-valuenow={maxVal}
aria-valuetext={usd.format(maxVal)}
onKeyDown={(e) => onThumbKey(e, "max")}
whileTap={reduce ? undefined : { scale: 1.18 }}
style={{ left: `${pct(maxVal)}%`, zIndex: active === "max" ? 30 : 20 }}
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none rounded-full border-2 border-violet-500 bg-white shadow-md shadow-slate-900/25 outline-none transition-shadow active:cursor-grabbing focus-visible:ring-4 focus-visible:ring-violet-500/30 dark:border-violet-400 dark:bg-slate-950"
/>
</div>
<p className="mt-3 text-xs text-slate-400 dark:text-slate-500">
Drag a handle, or focus it and use the arrow keys. Home and End
jump to the limits.
</p>
</div>
{/* Number inputs */}
<div className="mt-7 grid grid-cols-[1fr_auto_1fr] items-end gap-3">
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-slate-500 dark:text-slate-400">
Minimum
</span>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm font-medium text-slate-400 dark:text-slate-500">
$
</span>
<input
inputMode="numeric"
value={minText}
onChange={onMinChange}
onBlur={commitMinText}
onKeyDown={onInputKey}
aria-label="Minimum price in dollars"
className="w-full rounded-xl border border-slate-300 bg-white py-2.5 pl-7 pr-3 text-sm font-semibold text-slate-900 outline-none transition focus-visible:border-indigo-500 focus-visible:ring-4 focus-visible:ring-indigo-500/20 dark:border-slate-700 dark:bg-slate-950 dark:text-white"
/>
</div>
</label>
<span className="self-end pb-2.5 text-center text-sm text-slate-400 dark:text-slate-500">
–
</span>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-slate-500 dark:text-slate-400">
Maximum
</span>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm font-medium text-slate-400 dark:text-slate-500">
$
</span>
<input
inputMode="numeric"
value={maxText}
onChange={onMaxChange}
onBlur={commitMaxText}
onKeyDown={onInputKey}
aria-label="Maximum price in dollars"
className="w-full rounded-xl border border-slate-300 bg-white py-2.5 pl-7 pr-3 text-sm font-semibold text-slate-900 outline-none transition focus-visible:border-violet-500 focus-visible:ring-4 focus-visible:ring-violet-500/20 dark:border-slate-700 dark:bg-slate-950 dark:text-white"
/>
</div>
</label>
</div>
{/* Presets */}
<div className="mt-6 flex flex-wrap gap-2">
{PRESETS.map((p) => {
const on = p.min === minVal && p.max === maxVal;
return (
<button
key={p.label}
type="button"
onClick={() => applyPreset(p)}
aria-pressed={on}
className={`rounded-full border px-3.5 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-indigo-500/25 ${
on
? "border-indigo-500 bg-indigo-500 text-white shadow-sm shadow-indigo-500/30"
: "border-slate-300 bg-white text-slate-600 hover:border-indigo-400 hover:text-indigo-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-indigo-400 dark:hover:text-indigo-300"
}`}
>
{p.label}
</button>
);
})}
</div>
{/* Footer */}
<div className="mt-7 flex items-center justify-between gap-4 border-t border-slate-200 pt-5 dark:border-slate-800">
<p className="text-sm text-slate-500 dark:text-slate-400">
<span className="font-semibold text-slate-900 dark:text-white">
{usd.format(minVal)}
</span>{" "}
<span aria-hidden="true">–</span>{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{usd.format(maxVal)}
</span>
</p>
<button
type="button"
onClick={() => setApplied(true)}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-500/25 transition hover:from-indigo-600 hover:to-violet-600 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-indigo-500/40 active:scale-[0.98]"
>
{applied ? (
<>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" />
</svg>
Filters applied
</>
) : (
<>Show {int.format(resultCount)} results</>
)}
</button>
</div>
<div aria-live="polite" className="sr-only">
{int.format(resultCount)} wireless headphones between{" "}
{usd.format(minVal)} and {usd.format(maxVal)}.
</div>
</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

Range Dual Slider
Originaldual-handle min/max range

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

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

