Skip to content

Draggable Range Slider

A refined range slider, single or dual thumb: a value bubble that follows the handle and swells while dragging, tick marks with labels, a histogram behind the track that lights up inside the selection, step snapping with a small haptic-style bump, and full keyboard and touch support. No dependencies.

DOM + CSS0 deps1 file · 26 KB TSXreduced-motion aware

Freeminimalplayful
Preview

Props

0
100
40
Two handles
76
1
5
Histogram
No runtime dependencies
"use client";

import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";

export type RangeSliderProps = {
  min?: number;
  max?: number;
  /** Single mode: the value. Dual mode: the lower thumb. */
  defaultValue?: number;
  /** Dual mode only: the upper thumb (defaults to 75% of the way along). */
  defaultEnd?: number;
  /** Two thumbs and a lit range between them. */
  dual?: boolean;
  step?: number;
  /** Histogram drawn behind the track; the selected range lights up. */
  data?: number[];
  /** Number of labelled divisions under the track (0 = no marks). */
  ticks?: number;
  /** "active": bubble on drag, hover and keyboard focus. "always": a quiet bubble at rest. */
  bubble?: "active" | "always";
  /** Text for the bubble and aria-valuetext. */
  format?: (value: number) => string;
  /** Text for the tick labels (falls back to format). */
  tickFormat?: (value: number) => string;
  onChange?: (value: number | [number, number]) => void;
  disabled?: boolean;
  label?: string;
  /** Accent: #hex, rgb() or hsl(). */
  color?: string;
  /** The root is w-full and capped by --rs-max (20rem); override with className, e.g. [--rs-max:36rem]. */
  className?: string;
};

const RS_COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([0-9\s.,%/-]+\))$/i;
const RS_THUMB = 26;
const RS_ROW = 44;
const RS_TICKS_H = 28;

const rsClamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
const rsAt = <T,>(a: readonly T[], i: number): T => a[i] as T;
const rsDecimals = (s: number) => {
  const t = String(s);
  const i = t.indexOf(".");
  return i < 0 ? 0 : Math.min(6, t.length - i - 1);
};

type Sp = { x: number; v: number };
const rsSp = (x = 0): Sp => ({ x, v: 0 });
/** Semi-implicit Euler spring, 120 Hz substeps. Returns true while it is still moving. */
function rsStep(s: Sp, target: number, k: number, c: number, dt: number, still: boolean, eps: number): boolean {
  if (still) {
    s.x = target;
    s.v = 0;
    return false;
  }
  const n = Math.max(1, Math.ceil(dt * 120));
  const h = dt / n;
  for (let i = 0; i < n; i++) {
    s.v += (k * (target - s.x) - c * s.v) * h;
    s.x += s.v * h;
  }
  return Math.abs(target - s.x) > eps || Math.abs(s.v) > eps * 20;
}

function rsSubscribe(cb: () => void) {
  const m = window.matchMedia("(prefers-reduced-motion: reduce)");
  m.addEventListener("change", cb);
  return () => m.removeEventListener("change", cb);
}
function useRsReduced(): boolean {
  return useSyncExternalStore(
    rsSubscribe,
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false,
  );
}

const RS_CSS = [
  ".rs-bar{transform-origin:50% 100%;transition:transform .8s cubic-bezier(.2,.8,.2,1);transition-delay:var(--rs-d,0ms)}",
  "[data-rs-arm]:not([data-rs-live]) .rs-bar{transform:scaleY(0)}",
  "@media (prefers-reduced-motion:reduce){.rs-bar{transition:none}}",
].join("\n");

type Th = { pos: Sp; sc: Sp; halo: Sp; vis: Sp; infl: Sp; tilt: Sp };
const rsThumbSim = (r: number): Th => ({ pos: rsSp(r), sc: rsSp(1), halo: rsSp(0), vis: rsSp(0), infl: rsSp(0), tilt: rsSp(0) });

export function RangeSlider({
  min = 0,
  max = 100,
  defaultValue = 40,
  defaultEnd,
  dual = false,
  step = 1,
  data,
  ticks = 0,
  bubble = "active",
  format,
  tickFormat,
  onChange,
  disabled = false,
  label = "Value",
  color = "#ef4444",
  className,
}: RangeSliderProps) {
  const span = Math.max(max - min, 1e-9);
  const stp = step > 0 ? step : 1;
  const dec = rsDecimals(stp);
  const accent = RS_COLOR.test(color) ? color : "#ef4444";
  const reduced = useRsReduced();
  const fmt = format ?? ((v: number) => String(v));
  const tfmt = tickFormat ?? fmt;

  const snap = (raw: number) =>
    rsClamp(Number((min + Math.round((raw - min) / stp) * stp).toFixed(dec)), min, max);

  const series = data && data.length > 1 ? data.slice(0, 80).map((d) => (Number.isFinite(d) && d > 0 ? d : 0)) : [];
  const nBars = series.length;
  const hasData = nBars > 0;
  const seriesMax = Math.max(...series, 1e-9);
  const nTicks = rsClamp(Math.round(ticks), 0, 20);
  const sub = 4;
  const nMarks = nTicks > 0 ? nTicks * sub + 1 : 0;

  const [vals, setVals] = useState<[number, number]>(() => {
    const a = snap(defaultValue);
    if (!dual) return [min, a];
    const b = snap(defaultEnd ?? min + span * 0.75);
    return [Math.min(a, b), Math.max(a, b)];
  });
  const lo = dual ? rsClamp(vals[0], min, max) : min;
  const hi = rsClamp(vals[1], min, max);
  const valsRef = useRef<[number, number]>([lo, hi]);
  const live = useRef({ drag: -1, hover: false, focus: -1, kick: [0, 0], touch: false, grab: 0 });
  const wakeRef = useRef<(() => void) | null>(null);
  const cfgRef = useRef({ min, span, dual, reduced, always: bubble === "always", n: nBars, marks: nMarks, accent, merged: false, hist: hasData });
  const rootRef = useRef<HTMLDivElement>(null);
  const zoneRef = useRef<HTMLDivElement>(null);
  const thumbEls = useRef<(HTMLDivElement | null)[]>([null, null]);
  const haloEls = useRef<(HTMLSpanElement | null)[]>([null, null]);
  const bubEls = useRef<(HTMLDivElement | null)[]>([null, null]);
  const guideEls = useRef<(HTMLSpanElement | null)[]>([null, null]);
  const barEls = useRef<(HTMLDivElement | null)[]>([]);
  const barLit = useRef<(HTMLElement | null)[]>([]);
  const tickLine = useRef<(HTMLElement | null)[]>([]);
  const tickText = useRef<(HTMLElement | null)[]>([]);
  const [top, setTop] = useState(1);
  const [w, setW] = useState(0);

  // Initial paint values. They never change after mount, so React never rewrites what the loop owns.
  const [init] = useState(() => {
    const r = (v: number) => (v - min) / span;
    const a = dual ? r(lo) : 0;
    const b = r(hi);
    const always = bubble === "always";
    const cov: number[] = [];
    for (let j = 0; j < nBars; j++) {
      const s = j / nBars;
      const e = (j + 1) / nBars;
      cov.push(rsClamp((Math.min(b, e) - Math.max(a, s)) * nBars, 0, 1));
    }
    return {
      vars: { "--rs-a": a.toFixed(5), "--rs-b": b.toFixed(5) } as Record<string, string>,
      bubA: { left: (r(lo) * 100).toFixed(3) + "%", opacity: always ? 0.78 : 0, transform: "translate(-50%,-100%) scale(" + (always ? 0.86 : 0.55) + ")" },
      bubB: { left: (r(hi) * 100).toFixed(3) + "%", opacity: always ? 0.78 : 0, transform: "translate(-50%,-100%) scale(" + (always ? 0.86 : 0.55) + ")" },
      guideA: (r(lo) * 100).toFixed(3) + "%",
      guideB: (r(hi) * 100).toFixed(3) + "%",
      cov,
    };
  });

  const merged = dual && w > 0 && ((hi - lo) / span) * w < 58;

  useEffect(() => {
    valsRef.current = [lo, hi];
    cfgRef.current = { min, span, dual, reduced, always: bubble === "always", n: nBars, marks: nMarks, accent, merged, hist: hasData };
    wakeRef.current?.();
  }, [lo, hi, min, span, dual, reduced, bubble, nBars, nMarks, accent, merged, hasData]);

  // Width, for merging the two bubbles when the thumbs get close.
  useEffect(() => {
    const zone = zoneRef.current;
    if (!zone) return;
    const ro = new ResizeObserver(() => setW(zone.offsetWidth));
    ro.observe(zone);
    return () => ro.disconnect();
  }, []);

  // Histogram reveal: bars rise the first time the slider scrolls into view.
  useEffect(() => {
    const root = rootRef.current;
    if (!root || !hasData || reduced) return;
    root.setAttribute("data-rs-arm", "");
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          root.setAttribute("data-rs-live", "");
          io.disconnect();
        }
      },
      { threshold: 0.2 },
    );
    io.observe(root);
    return () => {
      io.disconnect();
      root.removeAttribute("data-rs-arm");
      root.removeAttribute("data-rs-live");
    };
  }, [hasData, reduced]);

  // The motion loop. Springs live here; it writes the DOM directly and sleeps when everything is at rest.
  useEffect(() => {
    const zone = zoneRef.current;
    if (!zone) return;
    const c0 = cfgRef.current;
    const v0 = valsRef.current;
    const th: Th[] = [rsThumbSim((v0[0] - c0.min) / c0.span), rsThumbSim((v0[1] - c0.min) / c0.span)];
    const mrg = rsSp(0);
    const lastIn: boolean[] = [];
    let raf = 0;
    let last = 0;

    const frame = (t: number) => {
      raf = 0;
      const dt = last ? Math.min(0.05, (t - last) / 1000) : 1 / 60;
      last = t;
      const c = cfgRef.current;
      const L = live.current;
      const vv = valsRef.current;
      const still = c.reduced;
      const rt = (v: number) => (v - c.min) / c.span;
      let mv = false;

      for (let i = 0; i < 2; i++) {
        const s = rsAt(th, i);
        const dragI = L.drag === i;
        const focI = L.focus === i;
        const k = L.kick[i] ?? 0;
        if (k && !still) {
          s.sc.v += k;
          L.kick[i] = 0;
        }
        mv = rsStep(s.pos, rt(rsAt(vv, i)), 620, 36, dt, still, 1e-5) || mv;
        mv = rsStep(s.sc, still ? 1 : dragI ? 1.2 : L.hover ? 1.1 : 1, 380, 17, dt, still, 1e-3) || mv;
        mv = rsStep(s.halo, dragI ? 1 : focI ? 0.7 : 0, 260, 26, dt, still, 1e-3) || mv;
        mv = rsStep(s.vis, dragI || focI ? 1 : L.hover ? 0.6 : 0, 300, 26, dt, still, 1e-3) || mv;
        mv = rsStep(s.infl, dragI ? 1 : focI ? 0.5 : 0, 160, 20, dt, still, 1e-3) || mv;
        mv = rsStep(s.tilt, still ? 0 : rsClamp(s.pos.v * 7, -10, 10), 260, 20, dt, still, 1e-2) || mv;
      }
      mv = rsStep(mrg, c.merged ? 1 : 0, 300, 28, dt, still, 1e-3) || mv;

      const t0 = rsAt(th, 0);
      const t1 = rsAt(th, 1);
      const a = c.dual ? t0.pos.x : 0;
      const b = t1.pos.x;
      zone.style.setProperty("--rs-a", a.toFixed(5));
      zone.style.setProperty("--rs-b", b.toFixed(5));
      const mid = (a + b) / 2;

      for (let i = 0; i < 2; i++) {
        const s = rsAt(th, i);
        const thumb = thumbEls.current[i];
        if (thumb) thumb.style.transform = "translate(-50%,-50%) scale(" + s.sc.x.toFixed(4) + ")";
        const halo = haloEls.current[i];
        if (halo) {
          halo.style.opacity = Math.max(0, s.halo.x).toFixed(3);
          halo.style.transform = "translate(-50%,-50%) scale(" + (0.6 + 0.55 * s.halo.x).toFixed(4) + ")";
        }
        const guide = guideEls.current[i];
        if (guide) {
          guide.style.left = (s.pos.x * 100).toFixed(3) + "%";
          guide.style.opacity = (Math.max(0, s.halo.x) * 0.9).toFixed(3);
        }
        const bub = bubEls.current[i];
        if (bub) {
          const m = c.dual ? mrg.x : 0;
          const x = s.pos.x + (mid - s.pos.x) * m;
          const base = c.always ? 0.78 : 0;
          const bs = c.always ? 0.86 : 0.55;
          const vis = rsClamp(s.vis.x, 0, 1.1);
          let op = base + (1 - base) * vis;
          if (i === 1 && c.dual) op *= 1 - rsClamp(m, 0, 1);
          const sc = bs + (1 - bs) * vis + 0.1 * s.halo.x;
          bub.style.left = (x * 100).toFixed(3) + "%";
          bub.style.opacity = rsClamp(op, 0, 1).toFixed(3);
          bub.style.transform =
            "translate(-50%,-100%) translateY(" + ((1 - vis) * 5).toFixed(2) + "px) rotate(" + s.tilt.x.toFixed(2) + "deg) scale(" + sc.toFixed(4) + ")";
        }
      }

      const n = c.n;
      if (n > 0) {
        for (let j = 0; j < n; j++) {
          const sj = j / n;
          const ej = (j + 1) / n;
          const cj = (j + 0.5) / n;
          const lit = barLit.current[j];
          if (lit) lit.style.opacity = rsClamp((Math.min(b, ej) - Math.max(a, sj)) * n, 0, 1).toFixed(3);
          const bar = barEls.current[j];
          if (bar) {
            let wv = 0;
            for (let i = 0; i < 2; i++) {
              if (i === 0 && !c.dual) continue;
              const s = rsAt(th, i);
              const d = (cj - s.pos.x) / 0.1;
              wv = Math.max(wv, s.infl.x * Math.exp(-d * d));
            }
            bar.style.transform = still ? "" : "scaleY(" + (1 + 0.28 * wv).toFixed(4) + ")";
          }
        }
      }

      const nm = c.marks;
      if (nm > 1) {
        for (let j = 0; j < nm; j++) {
          const p = j / (nm - 1);
          const inside = p >= a - 1e-4 && p <= b + 1e-4;
          let near = 0;
          for (let i = 0; i < 2; i++) {
            if (i === 0 && !c.dual) continue;
            const s = rsAt(th, i);
            const d = (p - s.pos.x) / 0.07;
            near = Math.max(near, Math.exp(-d * d) * (0.35 + 0.65 * s.infl.x));
          }
          const major = j % 4 === 0;
          const line = tickLine.current[j];
          if (line) {
            line.style.opacity = ((inside ? 0.8 : 0.3) + (major ? 0.1 : 0) + 0.5 * near).toFixed(3);
            line.style.transform = still ? "" : "scaleY(" + (1 + 0.7 * near).toFixed(3) + ")";
            if (lastIn[j] !== inside) {
              lastIn[j] = inside;
              line.style.backgroundColor = inside ? c.accent : "";
            }
          }
          const text = tickText.current[j];
          if (text) text.style.opacity = (0.45 + 0.5 * near).toFixed(3);
        }
      }

      if (mv) raf = requestAnimationFrame(frame);
      else last = 0;
    };

    const wake = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };
    wakeRef.current = wake;
    wake();
    return () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      wakeRef.current = null;
    };
  }, []);

  // ---- input ----
  const setThumb = (i: number, raw: number) => {
    const cur = valsRef.current;
    const idx = dual ? i : 1;
    let v = snap(raw);
    if (dual) v = idx === 0 ? Math.min(v, cur[1]) : Math.max(v, cur[0]);
    if (v === cur[idx]) return;
    const next: [number, number] = idx === 0 ? [v, cur[1]] : [cur[0], v];
    valsRef.current = next;
    setVals(next);
    const pxStep = ((zoneRef.current?.offsetWidth ?? 300) * stp) / span;
    live.current.kick[idx] = (live.current.kick[idx] ?? 0) + rsClamp(pxStep * 0.35, 0.25, 2.4);
    if (live.current.touch && pxStep >= 6 && typeof navigator.vibrate === "function") navigator.vibrate(3);
    wakeRef.current?.();
    onChange?.(dual ? next : next[1]);
  };

  const ratioAt = (clientX: number) => {
    const zone = zoneRef.current;
    if (!zone) return 0;
    const rect = zone.getBoundingClientRect();
    return rsClamp((clientX - rect.left) / Math.max(1, rect.width), 0, 1);
  };

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
    const zone = zoneRef.current;
    if (!zone) return;
    const r = ratioAt(e.clientX);
    const cur = valsRef.current;
    const ra = (cur[0] - min) / span;
    const rb = (cur[1] - min) / span;
    let idx = 1;
    if (dual) {
      const da = Math.abs(ra - r);
      const db = Math.abs(rb - r);
      idx = da < db ? 0 : da > db ? 1 : r < ra ? 0 : 1;
    }
    const rt = idx === 0 ? ra : rb;
    const grab = Math.abs(rt - r) * zone.offsetWidth <= RS_THUMB / 2 + 2 ? rt - r : 0;
    const L = live.current;
    L.drag = idx;
    L.grab = grab;
    L.touch = e.pointerType !== "mouse";
    setTop(idx);
    try {
      zone.setPointerCapture(e.pointerId);
    } catch {
      /* capture is a nicety */
    }
    thumbEls.current[idx]?.focus({ preventScroll: true });
    setThumb(idx, min + rsClamp(r + grab, 0, 1) * span);
    wakeRef.current?.();
  };

  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    const L = live.current;
    if (L.drag >= 0) {
      setThumb(L.drag, min + rsClamp(ratioAt(e.clientX) + L.grab, 0, 1) * span);
    } else if (e.pointerType === "mouse" && !disabled && !L.hover) {
      L.hover = true;
      wakeRef.current?.();
    }
  };

  const endDrag = () => {
    const L = live.current;
    if (L.drag < 0) return;
    L.drag = -1;
    wakeRef.current?.();
  };

  const onLeave = () => {
    live.current.hover = false;
    wakeRef.current?.();
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>, i: number) => {
    if (disabled) return;
    const cur = valsRef.current;
    const idx = dual ? i : 1;
    const v = cur[idx];
    const big = Math.max(stp, Math.round(span / 10));
    let target: number | null = null;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        target = v + (e.shiftKey ? big : stp);
        break;
      case "ArrowLeft":
      case "ArrowDown":
        target = v - (e.shiftKey ? big : stp);
        break;
      case "PageUp":
        target = v + big;
        break;
      case "PageDown":
        target = v - big;
        break;
      case "Home":
        target = idx === 1 && dual ? cur[0] : min;
        break;
      case "End":
        target = idx === 0 ? cur[1] : max;
        break;
      default:
        return;
    }
    e.preventDefault();
    live.current.touch = false;
    setThumb(idx, target);
  };

  const onFocusThumb = (i: number) => {
    let vis = true;
    try {
      vis = !!thumbEls.current[i]?.matches(":focus-visible");
    } catch {
      /* older engines: treat as visible */
    }
    live.current.focus = vis ? i : -1;
    setTop(i);
    wakeRef.current?.();
  };
  const onBlurThumb = () => {
    live.current.focus = -1;
    wakeRef.current?.();
  };

  // ---- layout ----
  const histH = "var(--rs-h)";
  const rowTop = "calc(" + histH + " + " + RS_ROW / 2 + "px)";
  const zoneStyle = {
    ...init.vars,
    "--rs-h": hasData ? "var(--rs-hist, 56px)" : "0px",
    height: "calc(" + histH + " + " + (RS_ROW + (nTicks > 0 ? RS_TICKS_H : 0)) + "px)",
    touchAction: "pan-y",
    WebkitTapHighlightColor: "transparent",
  } as CSSProperties;

  const thumbBg = "linear-gradient(180deg,#ffffff,#e9ebf0)";
  const thumbShadow =
    "inset 0 0 0 2px " + accent + ", 0 1px 2px rgba(0,0,0,.35), 0 8px 16px -6px color-mix(in srgb, " + accent + " 60%, transparent)";
  const bubbleText = (i: number) => (i === 0 ? (merged ? fmt(lo) + " – " + fmt(hi) : fmt(lo)) : fmt(hi));
  const idxs = dual ? [0, 1] : [1];

  const marks: { p: number; major: boolean; text: string }[] = [];
  for (let j = 0; j < nMarks; j++) {
    const p = j / (nMarks - 1);
    marks.push({ p, major: j % sub === 0, text: tfmt(Number((min + p * span).toFixed(Math.max(dec, 2)))) });
  }

  return (
    <div
      ref={rootRef}
      role={dual ? "group" : undefined}
      aria-label={dual ? label : undefined}
      aria-disabled={disabled || undefined}
      className={"relative w-full select-none px-3.5 pb-5 pt-12 " + (disabled ? "opacity-50 " : "") + (className ?? "")}
      style={{ maxWidth: "var(--rs-max, 20rem)" }}
    >
      <style>{RS_CSS}</style>
      <div
        ref={zoneRef}
        className={"relative w-full " + (disabled ? "cursor-not-allowed" : "cursor-pointer")}
        style={zoneStyle}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onLostPointerCapture={endDrag}
        onPointerLeave={onLeave}
      >
        {hasData && (
          <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 flex items-end gap-[2px]" style={{ height: histH }}>
            {series.map((d, j) => (
              <div
                key={j}
                ref={(el) => {
                  barEls.current[j] = el;
                }}
                className="flex h-full min-w-0 flex-1 items-end"
                style={{ transformOrigin: "50% 100%" }}
              >
                <span
                  className="rs-bar relative block w-full overflow-hidden rounded-t-[3px]"
                  style={{
                    height: Math.max(7, (d / seriesMax) * 78).toFixed(2) + "%",
                    background: "rgba(127,127,138,.3)",
                    ["--rs-d" as string]: j * 14 + "ms",
                  }}
                >
                  <i
                    ref={(el) => {
                      barLit.current[j] = el;
                    }}
                    className="absolute inset-0 block"
                    style={{
                      background: "linear-gradient(180deg,color-mix(in srgb, " + accent + " 78%, #fff)," + accent + ")",
                      opacity: init.cov[j] ?? 0,
                    }}
                  />
                </span>
              </div>
            ))}
          </div>
        )}

        {/* guides: a hairline from the active thumb up through the histogram */}
        {hasData &&
          idxs.map((i) => (
            <span
              key={"g" + i}
              ref={(el) => {
                guideEls.current[i] = el;
              }}
              aria-hidden
              className="pointer-events-none absolute w-px"
              style={{
                left: i === 0 ? init.guideA : init.guideB,
                top: 0,
                height: rowTop,
                opacity: 0,
                background: "linear-gradient(180deg, transparent, " + accent + ")",
              }}
            />
          ))}

        {/* track */}
        <div
          aria-hidden
          className="absolute inset-x-0 h-1.5 rounded-full"
          style={{ top: "calc(" + rowTop + " - 3px)", background: "rgba(127,127,138,.28)", boxShadow: "inset 0 1px 1px rgba(0,0,0,.18)" }}
        >
          <div
            className="absolute inset-y-0 rounded-full"
            style={{
              left: "calc(var(--rs-a) * 100%)",
              width: "calc((var(--rs-b) - var(--rs-a)) * 100%)",
              background: "linear-gradient(90deg,color-mix(in srgb, " + accent + " 78%, #fff)," + accent + ")",
              boxShadow: "0 0 14px -2px color-mix(in srgb, " + accent + " 55%, transparent)",
            }}
          />
        </div>

        {/* ticks */}
        {nMarks > 0 && (
          <div aria-hidden className="pointer-events-none absolute inset-x-0" style={{ top: "calc(" + histH + " + " + (RS_ROW - 2) + "px)" }}>
            {marks.map((m, j) => (
              <div key={j} className="absolute top-0" style={{ left: m.p * 100 + "%" }}>
                <i
                  ref={(el) => {
                    tickLine.current[j] = el;
                  }}
                  className="absolute left-0 top-0 block w-px"
                  style={{ height: m.major ? 9 : 5, transformOrigin: "50% 0", background: "currentColor", opacity: 0.3, marginLeft: -0.5 }}
                />
                {m.major && (
                  <span
                    ref={(el) => {
                      tickText.current[j] = el;
                    }}
                    className="absolute left-0 top-[13px] -translate-x-1/2 whitespace-nowrap text-[11px] leading-none tabular-nums"
                    style={{ opacity: 0.45 }}
                  >
                    {m.text}
                  </span>
                )}
              </div>
            ))}
          </div>
        )}

        {/* halos */}
        {idxs.map((i) => (
          <span
            key={"h" + i}
            ref={(el) => {
              haloEls.current[i] = el;
            }}
            aria-hidden
            className="pointer-events-none absolute rounded-full"
            style={{
              left: "calc(var(" + (i === 0 ? "--rs-a" : "--rs-b") + ") * 100%)",
              top: rowTop,
              width: 48,
              height: 48,
              opacity: 0,
              transform: "translate(-50%,-50%) scale(.6)",
              background: "radial-gradient(closest-side, color-mix(in srgb, " + accent + " 34%, transparent), transparent)",
            }}
          />
        ))}

        {/* thumbs */}
        {idxs.map((i) => {
          const v = i === 0 ? lo : hi;
          return (
            <div
              key={"t" + i}
              ref={(el) => {
                thumbEls.current[i] = el;
              }}
              role="slider"
              tabIndex={disabled ? -1 : 0}
              aria-orientation="horizontal"
              aria-valuemin={dual && i === 1 ? lo : min}
              aria-valuemax={dual && i === 0 ? hi : max}
              aria-valuenow={v}
              aria-valuetext={fmt(v)}
              aria-disabled={disabled || undefined}
              aria-label={dual ? label + (i === 0 ? " minimum" : " maximum") : label}
              onKeyDown={(e) => onKeyDown(e, i)}
              onFocus={() => onFocusThumb(i)}
              onBlur={onBlurThumb}
              className="absolute grid place-items-center rounded-full outline-none focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-[3px] focus-visible:![outline-color:var(--rs-ring)]"
              style={{
                ["--rs-ring" as string]: accent,
                left: "calc(var(" + (i === 0 ? "--rs-a" : "--rs-b") + ") * 100%)",
                top: rowTop,
                width: RS_THUMB,
                height: RS_THUMB,
                zIndex: top === i ? 20 : 10,
                transform: "translate(-50%,-50%)",
                background: thumbBg,
                boxShadow: thumbShadow,
              }}
            >
              <span aria-hidden className="block h-[7px] w-[7px] rounded-full" style={{ background: accent, opacity: 0.9 }} />
            </div>
          );
        })}

        {/* value bubbles */}
        {idxs.map((i) => {
          const b = i === 0 ? init.bubA : init.bubB;
          return (
            <div
              key={"b" + i}
              ref={(el) => {
                bubEls.current[i] = el;
              }}
              aria-hidden
              className="pointer-events-none absolute z-30 whitespace-nowrap rounded-lg px-2.5 py-1 font-mono text-[12px] font-semibold leading-[18px] tabular-nums"
              style={{
                top: hasData ? -12 : 1,
                left: b.left,
                opacity: b.opacity,
                transform: b.transform,
                transformOrigin: "50% 100%",
                background: "#111113",
                color: "#fff",
                boxShadow: "0 0 0 1px rgba(255,255,255,.14), 0 10px 22px -8px rgba(0,0,0,.6)",
              }}
            >
              {bubbleText(i)}
              <span
                aria-hidden
                className="absolute left-1/2 block h-[7px] w-[7px] rotate-45"
                style={{ bottom: -3, marginLeft: -3.5, background: "#111113", boxShadow: "1px 1px 0 rgba(255,255,255,.1)" }}
              />
            </div>
          );
        })}
      </div>
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

A custom track instead of a native range input, so every part can be styled. Values live in plain state (one number, or a low and high pair in dual mode) and snap to the step prop. A small spring loop, written by hand with no animation library, glides each handle to its snapped value, swells it while it is held, and gives it a tiny scale kick every time it crosses a step. The value bubble follows the handle, tilts with its speed and, with two handles close together, merges into one bubble that reads the whole range. Pass a data array and a histogram rises behind the track, with the bars inside the selection lit in the accent and the bars near the held handle lifting like a wave. The ticks prop adds hairline marks with labels that brighten as a handle passes. Press anywhere on the track to jump the nearest handle there; grab a handle directly and it keeps your offset. Touch drags horizontally while the page still scrolls vertically, and a coarse step buzzes lightly on phones that support it. It paints its own surfaces with a neutral track, an accent fill and a white handle, so it sits on a light or a dark page unchanged. The root is full width up to 20rem; override the cap with a className such as [--rs-max:36rem], and the histogram height with [--rs-hist:96px].

Curator’s note

Rebuilt in October as a proper product slider: one or two handles, a histogram that lights up inside the selection, and a value bubble that leans with the drag. The handles still read from a single value, so the fill, the bars and the thumbs cannot drift apart.

Pairs well with

Pro