{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"range-slider","type":"registry:component","title":"Draggable Range Slider","description":"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.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/range-slider.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\n\nexport type RangeSliderProps = {\n  min?: number;\n  max?: number;\n  /** Single mode: the value. Dual mode: the lower thumb. */\n  defaultValue?: number;\n  /** Dual mode only: the upper thumb (defaults to 75% of the way along). */\n  defaultEnd?: number;\n  /** Two thumbs and a lit range between them. */\n  dual?: boolean;\n  step?: number;\n  /** Histogram drawn behind the track; the selected range lights up. */\n  data?: number[];\n  /** Number of labelled divisions under the track (0 = no marks). */\n  ticks?: number;\n  /** \"active\": bubble on drag, hover and keyboard focus. \"always\": a quiet bubble at rest. */\n  bubble?: \"active\" | \"always\";\n  /** Text for the bubble and aria-valuetext. */\n  format?: (value: number) => string;\n  /** Text for the tick labels (falls back to format). */\n  tickFormat?: (value: number) => string;\n  onChange?: (value: number | [number, number]) => void;\n  disabled?: boolean;\n  label?: string;\n  /** Accent: #hex, rgb() or hsl(). */\n  color?: string;\n  /** The root is w-full and capped by --rs-max (20rem); override with className, e.g. [--rs-max:36rem]. */\n  className?: string;\n};\n\nconst RS_COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\\([0-9\\s.,%/-]+\\))$/i;\nconst RS_THUMB = 26;\nconst RS_ROW = 44;\nconst RS_TICKS_H = 28;\n\nconst rsClamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst rsAt = <T,>(a: readonly T[], i: number): T => a[i] as T;\nconst rsDecimals = (s: number) => {\n  const t = String(s);\n  const i = t.indexOf(\".\");\n  return i < 0 ? 0 : Math.min(6, t.length - i - 1);\n};\n\ntype Sp = { x: number; v: number };\nconst rsSp = (x = 0): Sp => ({ x, v: 0 });\n/** Semi-implicit Euler spring, 120 Hz substeps. Returns true while it is still moving. */\nfunction rsStep(s: Sp, target: number, k: number, c: number, dt: number, still: boolean, eps: number): boolean {\n  if (still) {\n    s.x = target;\n    s.v = 0;\n    return false;\n  }\n  const n = Math.max(1, Math.ceil(dt * 120));\n  const h = dt / n;\n  for (let i = 0; i < n; i++) {\n    s.v += (k * (target - s.x) - c * s.v) * h;\n    s.x += s.v * h;\n  }\n  return Math.abs(target - s.x) > eps || Math.abs(s.v) > eps * 20;\n}\n\nfunction rsSubscribe(cb: () => void) {\n  const m = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n}\nfunction useRsReduced(): boolean {\n  return useSyncExternalStore(\n    rsSubscribe,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst RS_CSS = [\n  \".rs-bar{transform-origin:50% 100%;transition:transform .8s cubic-bezier(.2,.8,.2,1);transition-delay:var(--rs-d,0ms)}\",\n  \"[data-rs-arm]:not([data-rs-live]) .rs-bar{transform:scaleY(0)}\",\n  \"@media (prefers-reduced-motion:reduce){.rs-bar{transition:none}}\",\n].join(\"\\n\");\n\ntype Th = { pos: Sp; sc: Sp; halo: Sp; vis: Sp; infl: Sp; tilt: Sp };\nconst rsThumbSim = (r: number): Th => ({ pos: rsSp(r), sc: rsSp(1), halo: rsSp(0), vis: rsSp(0), infl: rsSp(0), tilt: rsSp(0) });\n\nexport function RangeSlider({\n  min = 0,\n  max = 100,\n  defaultValue = 40,\n  defaultEnd,\n  dual = false,\n  step = 1,\n  data,\n  ticks = 0,\n  bubble = \"active\",\n  format,\n  tickFormat,\n  onChange,\n  disabled = false,\n  label = \"Value\",\n  color = \"#ef4444\",\n  className,\n}: RangeSliderProps) {\n  const span = Math.max(max - min, 1e-9);\n  const stp = step > 0 ? step : 1;\n  const dec = rsDecimals(stp);\n  const accent = RS_COLOR.test(color) ? color : \"#ef4444\";\n  const reduced = useRsReduced();\n  const fmt = format ?? ((v: number) => String(v));\n  const tfmt = tickFormat ?? fmt;\n\n  const snap = (raw: number) =>\n    rsClamp(Number((min + Math.round((raw - min) / stp) * stp).toFixed(dec)), min, max);\n\n  const series = data && data.length > 1 ? data.slice(0, 80).map((d) => (Number.isFinite(d) && d > 0 ? d : 0)) : [];\n  const nBars = series.length;\n  const hasData = nBars > 0;\n  const seriesMax = Math.max(...series, 1e-9);\n  const nTicks = rsClamp(Math.round(ticks), 0, 20);\n  const sub = 4;\n  const nMarks = nTicks > 0 ? nTicks * sub + 1 : 0;\n\n  const [vals, setVals] = useState<[number, number]>(() => {\n    const a = snap(defaultValue);\n    if (!dual) return [min, a];\n    const b = snap(defaultEnd ?? min + span * 0.75);\n    return [Math.min(a, b), Math.max(a, b)];\n  });\n  const lo = dual ? rsClamp(vals[0], min, max) : min;\n  const hi = rsClamp(vals[1], min, max);\n  const valsRef = useRef<[number, number]>([lo, hi]);\n  const live = useRef({ drag: -1, hover: false, focus: -1, kick: [0, 0], touch: false, grab: 0 });\n  const wakeRef = useRef<(() => void) | null>(null);\n  const cfgRef = useRef({ min, span, dual, reduced, always: bubble === \"always\", n: nBars, marks: nMarks, accent, merged: false, hist: hasData });\n  const rootRef = useRef<HTMLDivElement>(null);\n  const zoneRef = useRef<HTMLDivElement>(null);\n  const thumbEls = useRef<(HTMLDivElement | null)[]>([null, null]);\n  const haloEls = useRef<(HTMLSpanElement | null)[]>([null, null]);\n  const bubEls = useRef<(HTMLDivElement | null)[]>([null, null]);\n  const guideEls = useRef<(HTMLSpanElement | null)[]>([null, null]);\n  const barEls = useRef<(HTMLDivElement | null)[]>([]);\n  const barLit = useRef<(HTMLElement | null)[]>([]);\n  const tickLine = useRef<(HTMLElement | null)[]>([]);\n  const tickText = useRef<(HTMLElement | null)[]>([]);\n  const [top, setTop] = useState(1);\n  const [w, setW] = useState(0);\n\n  // Initial paint values. They never change after mount, so React never rewrites what the loop owns.\n  const [init] = useState(() => {\n    const r = (v: number) => (v - min) / span;\n    const a = dual ? r(lo) : 0;\n    const b = r(hi);\n    const always = bubble === \"always\";\n    const cov: number[] = [];\n    for (let j = 0; j < nBars; j++) {\n      const s = j / nBars;\n      const e = (j + 1) / nBars;\n      cov.push(rsClamp((Math.min(b, e) - Math.max(a, s)) * nBars, 0, 1));\n    }\n    return {\n      vars: { \"--rs-a\": a.toFixed(5), \"--rs-b\": b.toFixed(5) } as Record<string, string>,\n      bubA: { left: (r(lo) * 100).toFixed(3) + \"%\", opacity: always ? 0.78 : 0, transform: \"translate(-50%,-100%) scale(\" + (always ? 0.86 : 0.55) + \")\" },\n      bubB: { left: (r(hi) * 100).toFixed(3) + \"%\", opacity: always ? 0.78 : 0, transform: \"translate(-50%,-100%) scale(\" + (always ? 0.86 : 0.55) + \")\" },\n      guideA: (r(lo) * 100).toFixed(3) + \"%\",\n      guideB: (r(hi) * 100).toFixed(3) + \"%\",\n      cov,\n    };\n  });\n\n  const merged = dual && w > 0 && ((hi - lo) / span) * w < 58;\n\n  useEffect(() => {\n    valsRef.current = [lo, hi];\n    cfgRef.current = { min, span, dual, reduced, always: bubble === \"always\", n: nBars, marks: nMarks, accent, merged, hist: hasData };\n    wakeRef.current?.();\n  }, [lo, hi, min, span, dual, reduced, bubble, nBars, nMarks, accent, merged, hasData]);\n\n  // Width, for merging the two bubbles when the thumbs get close.\n  useEffect(() => {\n    const zone = zoneRef.current;\n    if (!zone) return;\n    const ro = new ResizeObserver(() => setW(zone.offsetWidth));\n    ro.observe(zone);\n    return () => ro.disconnect();\n  }, []);\n\n  // Histogram reveal: bars rise the first time the slider scrolls into view.\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root || !hasData || reduced) return;\n    root.setAttribute(\"data-rs-arm\", \"\");\n    const io = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((e) => e.isIntersecting)) {\n          root.setAttribute(\"data-rs-live\", \"\");\n          io.disconnect();\n        }\n      },\n      { threshold: 0.2 },\n    );\n    io.observe(root);\n    return () => {\n      io.disconnect();\n      root.removeAttribute(\"data-rs-arm\");\n      root.removeAttribute(\"data-rs-live\");\n    };\n  }, [hasData, reduced]);\n\n  // The motion loop. Springs live here; it writes the DOM directly and sleeps when everything is at rest.\n  useEffect(() => {\n    const zone = zoneRef.current;\n    if (!zone) return;\n    const c0 = cfgRef.current;\n    const v0 = valsRef.current;\n    const th: Th[] = [rsThumbSim((v0[0] - c0.min) / c0.span), rsThumbSim((v0[1] - c0.min) / c0.span)];\n    const mrg = rsSp(0);\n    const lastIn: boolean[] = [];\n    let raf = 0;\n    let last = 0;\n\n    const frame = (t: number) => {\n      raf = 0;\n      const dt = last ? Math.min(0.05, (t - last) / 1000) : 1 / 60;\n      last = t;\n      const c = cfgRef.current;\n      const L = live.current;\n      const vv = valsRef.current;\n      const still = c.reduced;\n      const rt = (v: number) => (v - c.min) / c.span;\n      let mv = false;\n\n      for (let i = 0; i < 2; i++) {\n        const s = rsAt(th, i);\n        const dragI = L.drag === i;\n        const focI = L.focus === i;\n        const k = L.kick[i] ?? 0;\n        if (k && !still) {\n          s.sc.v += k;\n          L.kick[i] = 0;\n        }\n        mv = rsStep(s.pos, rt(rsAt(vv, i)), 620, 36, dt, still, 1e-5) || mv;\n        mv = rsStep(s.sc, still ? 1 : dragI ? 1.2 : L.hover ? 1.1 : 1, 380, 17, dt, still, 1e-3) || mv;\n        mv = rsStep(s.halo, dragI ? 1 : focI ? 0.7 : 0, 260, 26, dt, still, 1e-3) || mv;\n        mv = rsStep(s.vis, dragI || focI ? 1 : L.hover ? 0.6 : 0, 300, 26, dt, still, 1e-3) || mv;\n        mv = rsStep(s.infl, dragI ? 1 : focI ? 0.5 : 0, 160, 20, dt, still, 1e-3) || mv;\n        mv = rsStep(s.tilt, still ? 0 : rsClamp(s.pos.v * 7, -10, 10), 260, 20, dt, still, 1e-2) || mv;\n      }\n      mv = rsStep(mrg, c.merged ? 1 : 0, 300, 28, dt, still, 1e-3) || mv;\n\n      const t0 = rsAt(th, 0);\n      const t1 = rsAt(th, 1);\n      const a = c.dual ? t0.pos.x : 0;\n      const b = t1.pos.x;\n      zone.style.setProperty(\"--rs-a\", a.toFixed(5));\n      zone.style.setProperty(\"--rs-b\", b.toFixed(5));\n      const mid = (a + b) / 2;\n\n      for (let i = 0; i < 2; i++) {\n        const s = rsAt(th, i);\n        const thumb = thumbEls.current[i];\n        if (thumb) thumb.style.transform = \"translate(-50%,-50%) scale(\" + s.sc.x.toFixed(4) + \")\";\n        const halo = haloEls.current[i];\n        if (halo) {\n          halo.style.opacity = Math.max(0, s.halo.x).toFixed(3);\n          halo.style.transform = \"translate(-50%,-50%) scale(\" + (0.6 + 0.55 * s.halo.x).toFixed(4) + \")\";\n        }\n        const guide = guideEls.current[i];\n        if (guide) {\n          guide.style.left = (s.pos.x * 100).toFixed(3) + \"%\";\n          guide.style.opacity = (Math.max(0, s.halo.x) * 0.9).toFixed(3);\n        }\n        const bub = bubEls.current[i];\n        if (bub) {\n          const m = c.dual ? mrg.x : 0;\n          const x = s.pos.x + (mid - s.pos.x) * m;\n          const base = c.always ? 0.78 : 0;\n          const bs = c.always ? 0.86 : 0.55;\n          const vis = rsClamp(s.vis.x, 0, 1.1);\n          let op = base + (1 - base) * vis;\n          if (i === 1 && c.dual) op *= 1 - rsClamp(m, 0, 1);\n          const sc = bs + (1 - bs) * vis + 0.1 * s.halo.x;\n          bub.style.left = (x * 100).toFixed(3) + \"%\";\n          bub.style.opacity = rsClamp(op, 0, 1).toFixed(3);\n          bub.style.transform =\n            \"translate(-50%,-100%) translateY(\" + ((1 - vis) * 5).toFixed(2) + \"px) rotate(\" + s.tilt.x.toFixed(2) + \"deg) scale(\" + sc.toFixed(4) + \")\";\n        }\n      }\n\n      const n = c.n;\n      if (n > 0) {\n        for (let j = 0; j < n; j++) {\n          const sj = j / n;\n          const ej = (j + 1) / n;\n          const cj = (j + 0.5) / n;\n          const lit = barLit.current[j];\n          if (lit) lit.style.opacity = rsClamp((Math.min(b, ej) - Math.max(a, sj)) * n, 0, 1).toFixed(3);\n          const bar = barEls.current[j];\n          if (bar) {\n            let wv = 0;\n            for (let i = 0; i < 2; i++) {\n              if (i === 0 && !c.dual) continue;\n              const s = rsAt(th, i);\n              const d = (cj - s.pos.x) / 0.1;\n              wv = Math.max(wv, s.infl.x * Math.exp(-d * d));\n            }\n            bar.style.transform = still ? \"\" : \"scaleY(\" + (1 + 0.28 * wv).toFixed(4) + \")\";\n          }\n        }\n      }\n\n      const nm = c.marks;\n      if (nm > 1) {\n        for (let j = 0; j < nm; j++) {\n          const p = j / (nm - 1);\n          const inside = p >= a - 1e-4 && p <= b + 1e-4;\n          let near = 0;\n          for (let i = 0; i < 2; i++) {\n            if (i === 0 && !c.dual) continue;\n            const s = rsAt(th, i);\n            const d = (p - s.pos.x) / 0.07;\n            near = Math.max(near, Math.exp(-d * d) * (0.35 + 0.65 * s.infl.x));\n          }\n          const major = j % 4 === 0;\n          const line = tickLine.current[j];\n          if (line) {\n            line.style.opacity = ((inside ? 0.8 : 0.3) + (major ? 0.1 : 0) + 0.5 * near).toFixed(3);\n            line.style.transform = still ? \"\" : \"scaleY(\" + (1 + 0.7 * near).toFixed(3) + \")\";\n            if (lastIn[j] !== inside) {\n              lastIn[j] = inside;\n              line.style.backgroundColor = inside ? c.accent : \"\";\n            }\n          }\n          const text = tickText.current[j];\n          if (text) text.style.opacity = (0.45 + 0.5 * near).toFixed(3);\n        }\n      }\n\n      if (mv) raf = requestAnimationFrame(frame);\n      else last = 0;\n    };\n\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(frame);\n    };\n    wakeRef.current = wake;\n    wake();\n    return () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n      wakeRef.current = null;\n    };\n  }, []);\n\n  // ---- input ----\n  const setThumb = (i: number, raw: number) => {\n    const cur = valsRef.current;\n    const idx = dual ? i : 1;\n    let v = snap(raw);\n    if (dual) v = idx === 0 ? Math.min(v, cur[1]) : Math.max(v, cur[0]);\n    if (v === cur[idx]) return;\n    const next: [number, number] = idx === 0 ? [v, cur[1]] : [cur[0], v];\n    valsRef.current = next;\n    setVals(next);\n    const pxStep = ((zoneRef.current?.offsetWidth ?? 300) * stp) / span;\n    live.current.kick[idx] = (live.current.kick[idx] ?? 0) + rsClamp(pxStep * 0.35, 0.25, 2.4);\n    if (live.current.touch && pxStep >= 6 && typeof navigator.vibrate === \"function\") navigator.vibrate(3);\n    wakeRef.current?.();\n    onChange?.(dual ? next : next[1]);\n  };\n\n  const ratioAt = (clientX: number) => {\n    const zone = zoneRef.current;\n    if (!zone) return 0;\n    const rect = zone.getBoundingClientRect();\n    return rsClamp((clientX - rect.left) / Math.max(1, rect.width), 0, 1);\n  };\n\n  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    const zone = zoneRef.current;\n    if (!zone) return;\n    const r = ratioAt(e.clientX);\n    const cur = valsRef.current;\n    const ra = (cur[0] - min) / span;\n    const rb = (cur[1] - min) / span;\n    let idx = 1;\n    if (dual) {\n      const da = Math.abs(ra - r);\n      const db = Math.abs(rb - r);\n      idx = da < db ? 0 : da > db ? 1 : r < ra ? 0 : 1;\n    }\n    const rt = idx === 0 ? ra : rb;\n    const grab = Math.abs(rt - r) * zone.offsetWidth <= RS_THUMB / 2 + 2 ? rt - r : 0;\n    const L = live.current;\n    L.drag = idx;\n    L.grab = grab;\n    L.touch = e.pointerType !== \"mouse\";\n    setTop(idx);\n    try {\n      zone.setPointerCapture(e.pointerId);\n    } catch {\n      /* capture is a nicety */\n    }\n    thumbEls.current[idx]?.focus({ preventScroll: true });\n    setThumb(idx, min + rsClamp(r + grab, 0, 1) * span);\n    wakeRef.current?.();\n  };\n\n  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const L = live.current;\n    if (L.drag >= 0) {\n      setThumb(L.drag, min + rsClamp(ratioAt(e.clientX) + L.grab, 0, 1) * span);\n    } else if (e.pointerType === \"mouse\" && !disabled && !L.hover) {\n      L.hover = true;\n      wakeRef.current?.();\n    }\n  };\n\n  const endDrag = () => {\n    const L = live.current;\n    if (L.drag < 0) return;\n    L.drag = -1;\n    wakeRef.current?.();\n  };\n\n  const onLeave = () => {\n    live.current.hover = false;\n    wakeRef.current?.();\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>, i: number) => {\n    if (disabled) return;\n    const cur = valsRef.current;\n    const idx = dual ? i : 1;\n    const v = cur[idx];\n    const big = Math.max(stp, Math.round(span / 10));\n    let target: number | null = null;\n    switch (e.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        target = v + (e.shiftKey ? big : stp);\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        target = v - (e.shiftKey ? big : stp);\n        break;\n      case \"PageUp\":\n        target = v + big;\n        break;\n      case \"PageDown\":\n        target = v - big;\n        break;\n      case \"Home\":\n        target = idx === 1 && dual ? cur[0] : min;\n        break;\n      case \"End\":\n        target = idx === 0 ? cur[1] : max;\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    live.current.touch = false;\n    setThumb(idx, target);\n  };\n\n  const onFocusThumb = (i: number) => {\n    let vis = true;\n    try {\n      vis = !!thumbEls.current[i]?.matches(\":focus-visible\");\n    } catch {\n      /* older engines: treat as visible */\n    }\n    live.current.focus = vis ? i : -1;\n    setTop(i);\n    wakeRef.current?.();\n  };\n  const onBlurThumb = () => {\n    live.current.focus = -1;\n    wakeRef.current?.();\n  };\n\n  // ---- layout ----\n  const histH = \"var(--rs-h)\";\n  const rowTop = \"calc(\" + histH + \" + \" + RS_ROW / 2 + \"px)\";\n  const zoneStyle = {\n    ...init.vars,\n    \"--rs-h\": hasData ? \"var(--rs-hist, 56px)\" : \"0px\",\n    height: \"calc(\" + histH + \" + \" + (RS_ROW + (nTicks > 0 ? RS_TICKS_H : 0)) + \"px)\",\n    touchAction: \"pan-y\",\n    WebkitTapHighlightColor: \"transparent\",\n  } as CSSProperties;\n\n  const thumbBg = \"linear-gradient(180deg,#ffffff,#e9ebf0)\";\n  const thumbShadow =\n    \"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)\";\n  const bubbleText = (i: number) => (i === 0 ? (merged ? fmt(lo) + \" – \" + fmt(hi) : fmt(lo)) : fmt(hi));\n  const idxs = dual ? [0, 1] : [1];\n\n  const marks: { p: number; major: boolean; text: string }[] = [];\n  for (let j = 0; j < nMarks; j++) {\n    const p = j / (nMarks - 1);\n    marks.push({ p, major: j % sub === 0, text: tfmt(Number((min + p * span).toFixed(Math.max(dec, 2)))) });\n  }\n\n  return (\n    <div\n      ref={rootRef}\n      role={dual ? \"group\" : undefined}\n      aria-label={dual ? label : undefined}\n      aria-disabled={disabled || undefined}\n      className={\"relative w-full select-none px-3.5 pb-5 pt-12 \" + (disabled ? \"opacity-50 \" : \"\") + (className ?? \"\")}\n      style={{ maxWidth: \"var(--rs-max, 20rem)\" }}\n    >\n      <style>{RS_CSS}</style>\n      <div\n        ref={zoneRef}\n        className={\"relative w-full \" + (disabled ? \"cursor-not-allowed\" : \"cursor-pointer\")}\n        style={zoneStyle}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onLostPointerCapture={endDrag}\n        onPointerLeave={onLeave}\n      >\n        {hasData && (\n          <div aria-hidden className=\"pointer-events-none absolute inset-x-0 top-0 flex items-end gap-[2px]\" style={{ height: histH }}>\n            {series.map((d, j) => (\n              <div\n                key={j}\n                ref={(el) => {\n                  barEls.current[j] = el;\n                }}\n                className=\"flex h-full min-w-0 flex-1 items-end\"\n                style={{ transformOrigin: \"50% 100%\" }}\n              >\n                <span\n                  className=\"rs-bar relative block w-full overflow-hidden rounded-t-[3px]\"\n                  style={{\n                    height: Math.max(7, (d / seriesMax) * 78).toFixed(2) + \"%\",\n                    background: \"rgba(127,127,138,.3)\",\n                    [\"--rs-d\" as string]: j * 14 + \"ms\",\n                  }}\n                >\n                  <i\n                    ref={(el) => {\n                      barLit.current[j] = el;\n                    }}\n                    className=\"absolute inset-0 block\"\n                    style={{\n                      background: \"linear-gradient(180deg,color-mix(in srgb, \" + accent + \" 78%, #fff),\" + accent + \")\",\n                      opacity: init.cov[j] ?? 0,\n                    }}\n                  />\n                </span>\n              </div>\n            ))}\n          </div>\n        )}\n\n        {/* guides: a hairline from the active thumb up through the histogram */}\n        {hasData &&\n          idxs.map((i) => (\n            <span\n              key={\"g\" + i}\n              ref={(el) => {\n                guideEls.current[i] = el;\n              }}\n              aria-hidden\n              className=\"pointer-events-none absolute w-px\"\n              style={{\n                left: i === 0 ? init.guideA : init.guideB,\n                top: 0,\n                height: rowTop,\n                opacity: 0,\n                background: \"linear-gradient(180deg, transparent, \" + accent + \")\",\n              }}\n            />\n          ))}\n\n        {/* track */}\n        <div\n          aria-hidden\n          className=\"absolute inset-x-0 h-1.5 rounded-full\"\n          style={{ top: \"calc(\" + rowTop + \" - 3px)\", background: \"rgba(127,127,138,.28)\", boxShadow: \"inset 0 1px 1px rgba(0,0,0,.18)\" }}\n        >\n          <div\n            className=\"absolute inset-y-0 rounded-full\"\n            style={{\n              left: \"calc(var(--rs-a) * 100%)\",\n              width: \"calc((var(--rs-b) - var(--rs-a)) * 100%)\",\n              background: \"linear-gradient(90deg,color-mix(in srgb, \" + accent + \" 78%, #fff),\" + accent + \")\",\n              boxShadow: \"0 0 14px -2px color-mix(in srgb, \" + accent + \" 55%, transparent)\",\n            }}\n          />\n        </div>\n\n        {/* ticks */}\n        {nMarks > 0 && (\n          <div aria-hidden className=\"pointer-events-none absolute inset-x-0\" style={{ top: \"calc(\" + histH + \" + \" + (RS_ROW - 2) + \"px)\" }}>\n            {marks.map((m, j) => (\n              <div key={j} className=\"absolute top-0\" style={{ left: m.p * 100 + \"%\" }}>\n                <i\n                  ref={(el) => {\n                    tickLine.current[j] = el;\n                  }}\n                  className=\"absolute left-0 top-0 block w-px\"\n                  style={{ height: m.major ? 9 : 5, transformOrigin: \"50% 0\", background: \"currentColor\", opacity: 0.3, marginLeft: -0.5 }}\n                />\n                {m.major && (\n                  <span\n                    ref={(el) => {\n                      tickText.current[j] = el;\n                    }}\n                    className=\"absolute left-0 top-[13px] -translate-x-1/2 whitespace-nowrap text-[11px] leading-none tabular-nums\"\n                    style={{ opacity: 0.45 }}\n                  >\n                    {m.text}\n                  </span>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n\n        {/* halos */}\n        {idxs.map((i) => (\n          <span\n            key={\"h\" + i}\n            ref={(el) => {\n              haloEls.current[i] = el;\n            }}\n            aria-hidden\n            className=\"pointer-events-none absolute rounded-full\"\n            style={{\n              left: \"calc(var(\" + (i === 0 ? \"--rs-a\" : \"--rs-b\") + \") * 100%)\",\n              top: rowTop,\n              width: 48,\n              height: 48,\n              opacity: 0,\n              transform: \"translate(-50%,-50%) scale(.6)\",\n              background: \"radial-gradient(closest-side, color-mix(in srgb, \" + accent + \" 34%, transparent), transparent)\",\n            }}\n          />\n        ))}\n\n        {/* thumbs */}\n        {idxs.map((i) => {\n          const v = i === 0 ? lo : hi;\n          return (\n            <div\n              key={\"t\" + i}\n              ref={(el) => {\n                thumbEls.current[i] = el;\n              }}\n              role=\"slider\"\n              tabIndex={disabled ? -1 : 0}\n              aria-orientation=\"horizontal\"\n              aria-valuemin={dual && i === 1 ? lo : min}\n              aria-valuemax={dual && i === 0 ? hi : max}\n              aria-valuenow={v}\n              aria-valuetext={fmt(v)}\n              aria-disabled={disabled || undefined}\n              aria-label={dual ? label + (i === 0 ? \" minimum\" : \" maximum\") : label}\n              onKeyDown={(e) => onKeyDown(e, i)}\n              onFocus={() => onFocusThumb(i)}\n              onBlur={onBlurThumb}\n              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)]\"\n              style={{\n                [\"--rs-ring\" as string]: accent,\n                left: \"calc(var(\" + (i === 0 ? \"--rs-a\" : \"--rs-b\") + \") * 100%)\",\n                top: rowTop,\n                width: RS_THUMB,\n                height: RS_THUMB,\n                zIndex: top === i ? 20 : 10,\n                transform: \"translate(-50%,-50%)\",\n                background: thumbBg,\n                boxShadow: thumbShadow,\n              }}\n            >\n              <span aria-hidden className=\"block h-[7px] w-[7px] rounded-full\" style={{ background: accent, opacity: 0.9 }} />\n            </div>\n          );\n        })}\n\n        {/* value bubbles */}\n        {idxs.map((i) => {\n          const b = i === 0 ? init.bubA : init.bubB;\n          return (\n            <div\n              key={\"b\" + i}\n              ref={(el) => {\n                bubEls.current[i] = el;\n              }}\n              aria-hidden\n              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\"\n              style={{\n                top: hasData ? -12 : 1,\n                left: b.left,\n                opacity: b.opacity,\n                transform: b.transform,\n                transformOrigin: \"50% 100%\",\n                background: \"#111113\",\n                color: \"#fff\",\n                boxShadow: \"0 0 0 1px rgba(255,255,255,.14), 0 10px 22px -8px rgba(0,0,0,.6)\",\n              }}\n            >\n              {bubbleText(i)}\n              <span\n                aria-hidden\n                className=\"absolute left-1/2 block h-[7px] w-[7px] rotate-45\"\n                style={{ bottom: -3, marginLeft: -3.5, background: \"#111113\", boxShadow: \"1px 1px 0 rgba(255,255,255,.1)\" }}\n              />\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/range-slider.tsx"}],"meta":{"tier":"free","tags":["slider","range","drag","input","dual thumb","histogram","filter","form"],"href":"https://viberdy.dev/library/range-slider","aiPrompt":"Create a single-file React + Tailwind component named RangeSlider (a \"use client\" file, React only, no animation library, no native input range).\n\nProps (all optional): min 0, max 100, defaultValue 40 (single mode: the value; dual mode: the lower thumb), defaultEnd (dual only, default 75% along), dual false, step 1, data (number[] histogram behind the track, up to 80 bars), ticks 0 (labelled divisions, max 20, each split into 4 minor marks), bubble \"active\" | \"always\" (default \"active\"), format (value to bubble and aria-valuetext text), tickFormat (falls back to format), onChange(value | [lo, hi]), disabled, label \"Value\", color \"#ef4444\" (validate with a hex/rgb()/hsl() regex, else the default), className. The root is w-full, px-3.5 pt-12 pb-5, max-width var(--rs-max, 20rem); histogram height var(--rs-hist, 56px). It paints its own surfaces and works on light and dark pages: track rgba(127,127,138,.28) with a faint inset shadow, tick/label colour currentColor.\n\nStructure: one pointer zone (touch-action pan-y, height = histogram + 44px row + 28px if ticks) containing: histogram bars (flex, gap 2px, bar height 7-78% of the histogram area, grey rgba(127,127,138,.3), each with an absolutely filled accent gradient layer whose opacity is the overlap of the bar with the selected range), hairline guides from active thumbs up through the histogram, the 6px track with a fill (gradient from accent mixed 78% with white to the accent, soft glow), ticks (1px, 9px major / 5px minor, under the row) with 11px tabular labels, a 48px radial halo behind each thumb, the thumbs (26px white-to-#e9ebf0 discs, 2px accent inset ring, 7px accent centre dot, drop shadow with accent tint), and the value bubbles (#111113, white 12px mono semibold tabular, rounded-lg, 1px white/14 ring, a rotated square notch, placed above the histogram, or just above the thumb with no data). Thumb and fill positions come from two CSS variables --rs-a and --rs-b (ratios 0..1; a is 0 in single mode) set on the zone.\n\nValue model: snap(raw) = clamp(min + round((raw - min)/step)*step) rounded to step decimals. Dual thumbs cannot cross. Pointerdown on the zone picks the nearest thumb (ties: the lower one if the pointer is left of it), jumps it to the pointer, or, when the pointer lands within 15px of the thumb, keeps the grab offset; the pointer is captured, the thumb is focused with preventScroll. Pointer ratio uses getBoundingClientRect so it is correct under CSS transform scale.\n\nMotion: one requestAnimationFrame loop inside a mount effect, semi-implicit Euler springs at 120 Hz substeps, sleeping when all springs rest. Thumb position k=620 c=36; thumb scale k=380 c=17 (rest 1, hover 1.1, dragging 1.2); each time the snapped value changes, add a velocity kick to the scale of clamp(pxPerStep*0.35, 0.25, 2.4); halo k=260 c=26; bubble visibility k=300 c=26; bubble tilt = clamp(thumb velocity*7, -10, 10) degrees (k=260 c=20). Bubble scale = base + (1 - base)*vis + 0.1*halo with base .86 (always) or .55, opacity base .78 (always) or 0, rises 5px as it appears. When two bubbles are within 58px they merge (spring k=300 c=28) into the lower one at the midpoint showing \"lo - hi\" while the other fades. Histogram wave: bars within a gaussian (sigma .1) of the held thumb scale Y up to 1.28. Ticks near a thumb grow up to 1.7x and brighten, ticks inside the range take the accent, labels brighten to .95 opacity. Bars rise on first intersection (IntersectionObserver, scaleY 0 to 1, 0.8s cubic-bezier(.2,.8,.2,1), 14ms stagger), only armed once JS runs. On touch with a coarse step (>= 6px) call navigator.vibrate(3) when available.\n\nKeyboard: each thumb is role=slider tabIndex 0 with aria-valuemin/max (dual: the limits follow the other thumb), aria-valuenow, aria-valuetext, aria-orientation, aria-label (dual: label + minimum/maximum, wrapped in role=group). Arrows step, Shift+Arrow / PageUp / PageDown move round(range/10) (at least one step), Home / End jump to the limit. Focus ring: important utilities, 2px outline offset 3px, accent colour, border-radius full. Bubble shows on keyboard focus-visible.\n\nprefers-reduced-motion: positions snap, no kicks, tilt, wave, reveal; the bubble only fades. Disabled: opacity .5, not-allowed cursor, tabIndex -1, aria-disabled. Animate only through direct DOM writes from the loop; React renders the initial values once.","license":"https://viberdy.dev/license"}}