Web InnoventixFreeCode

Price Range Slider

Original · free

price range filter slider

byWeb InnoventixReact + Tailwind
sldpricerangesliders
Open

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.json
sld-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 quote

Similar components

Browse all →