Web InnoventixFreeCode

Range Dual Slider

Original · free

dual-handle min/max range

byWeb InnoventixReact + Tailwind
sldrangedualsliders
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-range-dual.json
sld-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 &middot; Dec 12&ndash;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">
                  &ndash;
                </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"
            >
              &ndash;
            </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 quote

Similar components

Browse all →