{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "range-histogram-slider",
  "type": "registry:ui",
  "title": "Range histogram slider",
  "description": "A price range over a live histogram of the data, lighting the bars inside the range and settling on bar edges.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/range-histogram-slider/range-histogram-slider.tsx",
      "type": "registry:component",
      "target": "@components/xzero/range-histogram-slider/range-histogram-slider.tsx",
      "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./range-histogram-slider.module.css\";\n\nexport interface RangeHistogramSliderProps {\n  /** Visible label. The thumbs are named from it unless `thumbLabels` says otherwise. */\n  label: string;\n  /** The raw values the histogram counts, such as every listing's price. */\n  data: number[];\n  value?: [number, number];\n  defaultValue?: [number, number];\n  onValueChange?: (value: [number, number]) => void;\n  /** Runs once a drag is released or a key changes the value. */\n  onValueCommit?: (value: [number, number]) => void;\n  /** Defaults to the smallest and largest data values. */\n  min?: number;\n  max?: number;\n  step?: number;\n  /** How many bars the histogram draws. */\n  bins?: number;\n  /** A released thumb that lands near the edge of a bar settles on that edge, so a range never cuts a bar in half. */\n  snapToBins?: boolean;\n  /** The closest the two thumbs may sit, in steps. */\n  minStepsBetweenThumbs?: number;\n  /** Formats the readout and the spoken values. */\n  format?: (value: number) => string;\n  thumbLabels?: [string, string];\n  /** Submits the range with a form, one hidden input per thumb. */\n  name?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Drag = { pointer: number; index: number | null; grab: number; x: number; raw: number; samples: { t: number; p: number }[] };\n\nconst { spring } = motionTokens;\n/** Motion drops velocity on time-defined springs, so anything a gesture hands off to runs the same spring written as stiffness and damping. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta: .002, restSpeed: .02 }; };\nconst thumbSpring = physical(spring.snappy);\nconst kickSpring = physical(spring.morph);\n/** Pixels of rubber-band travel past a limit, pixel reach of a bar edge on release, and the speed a key press at a limit strains with. */\nconst STRETCH = 10, BIN_SNAP_PX = 9, BUMP_PX = 150;\nconst rubber = (distance: number, limit = STRETCH) => Math.sign(distance) * (1 - 1 / (Math.abs(distance) * .55 / limit + 1)) * limit;\n/** Where a release is heading, from a scroll-like deceleration (rate .99), in the same units as the velocity. */\nconst project = (velocity: number) => velocity * .099;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst decimalsOf = (value: number) => (String(value).split(\".\")[1] ?? \"\").length;\n\n/** Counts values into equal bars between `min` and `max`. The last bar keeps `max` itself. */\nexport function histogram(data: number[], min: number, max: number, bins: number) {\n  const counts = new Array<number>(bins).fill(0);\n  const width = (max - min) / bins || 1;\n  for (const item of data) if (item >= min && item <= max) counts[Math.min(bins - 1, Math.floor((item - min) / width))]!++;\n  return counts;\n}\n\n/**\n * A two thumb range over a histogram of the data it filters. Bars inside the range light up, cut exactly at each thumb on the frame it\n * moves; the thumbs follow the pointer 1:1, never cross, rubber-band at the ends, and settle on bar edges with the release velocity. A\n * press anywhere on the histogram or track springs the nearest thumb there. Arrows step, Shift+arrows and PageUp or PageDown move one\n * bar, and Home or End go to a thumb's limit.\n */\nexport function RangeHistogramSlider({ label, data, value, defaultValue, onValueChange, onValueCommit, min: minProp, max: maxProp, step: stepProp = 1, bins = 32, snapToBins = true, minStepsBetweenThumbs = 1, format, thumbLabels, name, disabled, className }: RangeHistogramSliderProps) {\n  const reduced = useReducedMotion() ?? false;\n  const labelId = useId();\n  const step = stepProp > 0 ? stepProp : 1;\n  const min = minProp ?? Math.floor(Math.min(...data, 0));\n  const max = maxProp ?? Math.ceil(Math.max(...data, min + step));\n  const span = max - min || 1;\n  const decimals = Math.max(decimalsOf(step), decimalsOf(min));\n  const gap = minStepsBetweenThumbs * step;\n  const [internal, setInternal] = useState<[number, number]>(() => defaultValue ?? [min, max]);\n  const values: [number, number] = value ?? internal;\n  const formatValue = format ?? ((input: number) => input.toLocaleString(\"en-US\", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }));\n  const counts = useMemo(() => histogram(data, min, max, bins), [data, min, max, bins]);\n  const tallest = Math.max(1, ...counts);\n  const binSpan = span / bins;\n\n  const pct = (input: number) => ((input - min) / span) * 100;\n  const valueAt = (percent: number) => min + (percent / 100) * span;\n  const snap = (input: number) => Number((min + Math.round((input - min) / step) * step).toFixed(decimals));\n  const lowOf = (index: number, current: [number, number]) => index === 1 ? current[0] + gap : min;\n  const highOf = (index: number, current: [number, number]) => index === 0 ? current[1] - gap : max;\n  const settle = (index: number, input: number, current: [number, number]) => clamp(snap(clamp(input, min, max)), lowOf(index, current), highOf(index, current));\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const thumbsRef = useRef<HTMLDivElement>(null);\n  const latest = useRef(values);\n  const goal = useRef(values.map(pct));\n  const drag = useRef<Drag | null>(null);\n  const [dragging, setDragging] = useState<number | null>(null);\n  const [lastActive, setLastActive] = useState(1);\n\n  const pos0 = useMotionValue(pct(values[0])), pos1 = useMotionValue(pct(values[1]));\n  const lag0 = useMotionValue(0), lag1 = useMotionValue(0);\n  const shown0 = useTransform(() => pos0.get() + lag0.get());\n  const shown1 = useTransform(() => pos1.get() + lag1.get());\n  // One clip reveals the lit copy of the bars and the track fill, so both cut at the thumb on the same frame.\n  const clipPath = useTransform(() => `inset(0 ${(100 - clamp(shown1.get(), 0, 100)).toFixed(3)}% 0 ${clamp(shown0.get(), 0, 100).toFixed(3)}%)`);\n  const thumbs = [{ pos: pos0, lag: lag0, shown: shown0 }, { pos: pos1, lag: lag1, shown: shown1 }];\n\n  function commit(index: number, next: number) {\n    const current = latest.current;\n    if (current[index] === next) return;\n    const updated: [number, number] = index === 0 ? [next, current[1]] : [current[0], next];\n    latest.current = updated;\n    if (value === undefined) setInternal(updated);\n    onValueChange?.(updated);\n  }\n\n  useLayoutEffect(() => {\n    latest.current = values;\n    values.forEach((item, index) => {\n      const target = pct(item), thumb = thumbs[index]!;\n      if (drag.current?.index === index || goal.current[index] === target) return;\n      goal.current[index] = target;\n      const from = thumb.pos.get() + thumb.lag.get();\n      thumb.lag.jump(0);\n      thumb.pos.jump(from);\n      if (reduced) thumb.pos.jump(target); else animate(thumb.pos, target, thumbSpring);\n    });\n  });\n\n  const trackWidth = () => trackRef.current?.getBoundingClientRect().width || 1;\n  const nearest = (at: number, current: [number, number]) => {\n    const low = Math.abs(at - pct(current[0])), high = Math.abs(at - pct(current[1]));\n    return low === high ? (at < pct(current[0]) ? 0 : 1) : low < high ? 0 : 1;\n  };\n  const focusThumb = (index: number) => thumbsRef.current?.querySelector<HTMLElement>(`[data-index=\"${index}\"]`)?.focus({ preventScroll: true });\n\n  function begin(state: Drag, index: number, at: number, press: boolean) {\n    const thumb = thumbs[index]!;\n    const from = thumb.pos.get() + thumb.lag.get();\n    state.index = index;\n    state.grab = press ? 0 : at - from;\n    thumb.lag.jump(0);\n    thumb.pos.jump(from);\n    setDragging(index);\n    setLastActive(index);\n    focusThumb(index);\n    if (press) follow(state, at, 0, true);\n    state.samples = [{ t: 0, p: thumb.pos.get() }];\n  }\n  function follow(state: Drag, at: number, time: number, press = false) {\n    const index = state.index;\n    if (index === null) return;\n    const thumb = thumbs[index]!, current = latest.current, width = trackWidth();\n    const raw = at - state.grab;\n    const edge = clamp(raw, pct(lowOf(index, current)), pct(highOf(index, current)));\n    const placed = reduced ? edge : edge + (rubber(((raw - edge) / 100) * width) / width) * 100;\n    if (press && !reduced) {\n      const from = thumb.pos.get();\n      thumb.pos.jump(placed);\n      thumb.lag.jump(from - placed);\n      animate(thumb.lag, 0, thumbSpring);\n    } else thumb.pos.set(placed);\n    state.raw = raw;\n    state.samples.push({ t: time, p: placed });\n    if (state.samples.length > 6) state.samples.shift();\n    commit(index, settle(index, valueAt(raw), current));\n  }\n\n  function onPointerDown(event: PointerEvent<HTMLDivElement>) {\n    if (disabled || event.button !== 0 || !event.isPrimary) return;\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const at = ((event.clientX - rect.left) / (rect.width || 1)) * 100;\n    const grabbed = (event.target as HTMLElement).closest<HTMLElement>(\"[data-thumb]\");\n    const current = latest.current;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const state: Drag = { pointer: event.pointerId, index: null, grab: 0, x: event.clientX, raw: at, samples: [] };\n    drag.current = state;\n    // Stacked thumbs wait for the first movement to decide which one the pointer means.\n    if (grabbed && current[1] - current[0] <= gap) { focusThumb(Number(grabbed.dataset.index)); return; }\n    begin(state, grabbed ? Number(grabbed.dataset.index) : nearest(at, current), at, !grabbed);\n    state.samples = [{ t: event.timeStamp, p: thumbs[state.index ?? 0]!.pos.get() }];\n  }\n  function onPointerMove(event: PointerEvent<HTMLDivElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const at = ((event.clientX - rect.left) / (rect.width || 1)) * 100;\n    if (state.index === null) {\n      const dx = event.clientX - state.x;\n      if (Math.abs(dx) < 2) return;\n      begin(state, dx < 0 ? 0 : 1, at - (dx / (rect.width || 1)) * 100, false);\n      state.samples = [{ t: event.timeStamp, p: thumbs[state.index ?? 0]!.pos.get() }];\n    }\n    follow(state, at, event.timeStamp);\n  }\n  function onPointerEnd(event: PointerEvent<HTMLDivElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    drag.current = null;\n    const index = state.index;\n    if (index === null) return;\n    const thumb = thumbs[index]!, current = latest.current, width = trackWidth();\n    const samples = state.samples, first = samples[0], last = samples[samples.length - 1];\n    const elapsed = last && first ? (last.t - first.t) / 1000 : 0;\n    const velocity = (elapsed > .008 && event.timeStamp - last!.t < 60 ? (last!.p - first!.p) / elapsed : 0) + thumb.lag.getVelocity();\n    // Momentum may carry the thumb at most one bar further; then a nearby bar edge claims it, so the range lines up with the data.\n    const binPct = 100 / bins;\n    const landing = clamp(state.raw + (event.type === \"pointerup\" ? clamp(project(velocity), -binPct, binPct) : 0), 0, 100);\n    let target = valueAt(landing);\n    if (snapToBins) {\n      const edgeValue = min + Math.round((target - min) / binSpan) * binSpan;\n      if ((Math.abs(pct(edgeValue) - landing) / 100) * width <= BIN_SNAP_PX) target = edgeValue;\n    }\n    const next = settle(index, target, current);\n    const from = thumb.pos.get() + thumb.lag.get(), goalPct = pct(next);\n    thumb.lag.jump(0);\n    thumb.pos.jump(from);\n    goal.current[index] = goalPct;\n    if (reduced) thumb.pos.jump(goalPct); else animate(thumb.pos, goalPct, { ...thumbSpring, velocity });\n    commit(index, next);\n    setDragging(null);\n    onValueCommit?.(latest.current);\n  }\n\n  function onKeyDown(index: number, event: KeyboardEvent<HTMLDivElement>) {\n    if (disabled) return;\n    const current = latest.current, now = current[index]!;\n    // A large move goes to the next bar edge in that direction, so keys walk the histogram bar by bar.\n    const edgeFrom = (direction: number) => { const at = (now - min) / binSpan; const next = direction > 0 ? Math.floor(at + 1e-6) + 1 : Math.ceil(at - 1e-6) - 1; return min + next * binSpan; };\n    const moves: Record<string, number> = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1, PageUp: 2, PageDown: -2 };\n    let wanted: number;\n    if (event.key === \"Home\") wanted = lowOf(index, current);\n    else if (event.key === \"End\") wanted = highOf(index, current);\n    else if (event.key in moves) { const direction = Math.sign(moves[event.key]!); wanted = Math.abs(moves[event.key]!) === 2 || event.shiftKey ? edgeFrom(direction) : now + direction * step; }\n    else return;\n    event.preventDefault();\n    setLastActive(index);\n    const next = settle(index, wanted, current);\n    if (next === now) {\n      const toward = Math.sign(wanted - now);\n      if (toward && !reduced) animate(thumbs[index]!.pos, goal.current[index]!, { ...kickSpring, velocity: (toward * BUMP_PX / trackWidth()) * 100 });\n      return;\n    }\n    commit(index, next);\n    onValueCommit?.(latest.current);\n  }\n\n  const names = thumbLabels ?? [`${label}, minimum`, `${label}, maximum`];\n  const bars = counts.map((count, index) => <span key={index} className={styles.bar} style={{ height: `${count ? Math.max(4, (count / tallest) * 100) : 0}%` }} />);\n\n  return <div className={[styles.root, className].filter(Boolean).join(\" \")} data-disabled={disabled || undefined} data-dragging={dragging !== null || undefined}>\n    <div className={styles.header}>\n      <span id={labelId} className={styles.label}>{label}</span>\n      <span className={styles.readout} aria-hidden=\"true\">{formatValue(values[0])}<span className={styles.dash}>–</span>{formatValue(values[1])}</span>\n    </div>\n    <div className={styles.control} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd} onLostPointerCapture={onPointerEnd} onMouseDown={event => event.preventDefault()}>\n      <div className={styles.chart} aria-hidden=\"true\">\n        <div className={styles.bars}>{bars}</div>\n        <motion.div className={`${styles.bars} ${styles.lit}`} style={{ clipPath }}>{bars}</motion.div>\n      </div>\n      <div className={styles.row}>\n        <div ref={trackRef} className={styles.track}><motion.div className={styles.fill} style={{ clipPath }} /></div>\n        <div ref={thumbsRef} className={styles.thumbs}>\n          {values.map((item, index) => <Thumb key={index} index={index} shown={thumbs[index]!.shown} value={item} text={formatValue(item)} low={lowOf(index, values)} high={highOf(index, values)} label={names[index]!}\n            active={lastActive === index} lifted={dragging === index} disabled={disabled} reduced={reduced} onKeyDown={event => onKeyDown(index, event)} />)}\n        </div>\n      </div>\n    </div>\n    {name && values.map((item, index) => <input key={index} type=\"hidden\" name={name} value={item} disabled={disabled} />)}\n  </div>;\n}\n\ninterface ThumbProps { index: number; shown: MotionValue<number>; value: number; text: string; low: number; high: number; label: string; active: boolean; lifted: boolean; disabled?: boolean; reduced: boolean; onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void; }\n\n/** The thumb rides a layer as wide as the track, so a percentage transform places it without measuring. */\nfunction Thumb({ index, shown, value, text, low, high, label, active, lifted, disabled, reduced, onKeyDown }: ThumbProps) {\n  const x = useTransform(shown, current => `${current - 100}%`);\n  return <motion.div className={styles.layer} style={{ x, zIndex: active ? 2 : 1 }}>\n    <span className={styles.guide} data-on={lifted || undefined} aria-hidden=\"true\" />\n    <motion.div role=\"slider\" data-thumb=\"\" data-index={index} className={styles.thumb} tabIndex={disabled ? -1 : 0} aria-label={label} aria-valuemin={low} aria-valuemax={high} aria-valuenow={value} aria-valuetext={text} aria-orientation=\"horizontal\" aria-disabled={disabled || undefined}\n      initial={false} animate={{ scale: lifted ? 1.18 : 1 }} transition={reduced ? { duration: 0 } : spring.snappy} onKeyDown={onKeyDown} />\n  </motion.div>;\n}\n\nexport default RangeHistogramSlider;\n"
    },
    {
      "path": "registry/components/range-histogram-slider/range-histogram-slider.module.css",
      "type": "registry:component",
      "target": "@components/xzero/range-histogram-slider/range-histogram-slider.module.css",
      "content": "/* Set --histogram-height, --histogram-thumb, and --histogram-track to resize. Chart and track share an inset of half a thumb, so every bar sits over the values it counts. */\n.root { --histogram-height: 76px; --histogram-thumb: 22px; --histogram-track: 4px; display: grid; width: 100%; min-width: 0; gap: var(--space-3); color: var(--foreground); font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); user-select: none; -webkit-user-select: none; }\n.header { display: flex; min-width: 0; align-items: baseline; justify-content: space-between; gap: var(--space-4); }\n.label { min-width: 0; font-weight: 500; }\n.readout { display: inline-flex; flex: none; align-items: baseline; gap: .3em; color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; }\n.dash { color: var(--text-muted); }\n/* The chart and the track are one hit area; vertical pans still scroll the page on touch. */\n.control { position: relative; display: grid; cursor: pointer; touch-action: pan-y; -webkit-tap-highlight-color: transparent; }\n.chart { position: relative; height: var(--histogram-height); margin: 0 calc(var(--histogram-thumb) / 2); }\n.bars { position: absolute; inset: 0; display: flex; align-items: flex-end; gap: 2px; }\n.bar { flex: 1 1 0; min-width: 0; border-radius: 3px 3px 1px 1px; background: color-mix(in oklab, var(--foreground) 11%, transparent); }\n/* The lit copy sits on top and is revealed between the thumbs, so a bar a thumb stops in is cut exactly there. */\n.lit .bar { background: var(--control-fill); }\n.row { position: relative; height: var(--control-height-sm); }\n.track { position: absolute; top: 50%; right: calc(var(--histogram-thumb) / 2); left: calc(var(--histogram-thumb) / 2); height: var(--histogram-track); margin-top: calc(var(--histogram-track) / -2); border-radius: var(--radius-pill); background: var(--control-track); }\n.fill { position: absolute; inset: 0; border-radius: inherit; background: var(--control-fill); }\n.thumbs { position: absolute; inset: 0 calc(var(--histogram-thumb) / 2); pointer-events: none; }\n.layer { position: absolute; inset: 0; }\n/* While a thumb is held, a hairline rises from it through the chart to show exactly where the range cuts. */\n.guide { position: absolute; right: 0; bottom: 50%; width: 1px; height: calc(var(--histogram-height) + 50% + 2px); translate: 50% 0; background: color-mix(in oklab, var(--foreground) 40%, transparent); opacity: 0; transform: scaleY(.6); transform-origin: 50% 100%; transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-standard) var(--ease-enter); }\n.guide[data-on] { opacity: 1; transform: none; }\n.thumb { position: absolute; top: 50%; right: 0; box-sizing: border-box; width: var(--histogram-thumb); height: var(--histogram-thumb); translate: 50% -50%; border: 0; border-radius: var(--radius-pill); background: var(--control-thumb); box-shadow: var(--control-thumb-shadow); pointer-events: auto; cursor: grab; }\n.thumb::after { content: \"\"; position: absolute; inset: -11px; border-radius: inherit; }\n.root[data-dragging], .root[data-dragging] .control, .root[data-dragging] .thumb { cursor: grabbing; }\n.root[data-disabled] { opacity: .5; }\n.root[data-disabled] .control, .root[data-disabled] .thumb { cursor: not-allowed; }\n@media (prefers-reduced-motion: reduce) { .guide { transition: none; } .guide { transform: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/range-histogram-slider",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/range-histogram-slider",
    "markdown": "https://ui.xzero.co.uk/components/range-histogram-slider/markdown"
  }
}