{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ruler-slider",
  "type": "registry:ui",
  "title": "Ruler slider",
  "description": "A tape of ticks that slides under a fixed needle, with momentum, snapping, and a rolling readout.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/ruler-slider/ruler-slider.tsx",
      "type": "registry:component",
      "target": "@components/xzero/ruler-slider/ruler-slider.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./ruler-slider.module.css\";\n\nexport interface RulerSliderProps {\n  /** Visible label; it also names the control. */\n  label: string;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Runs once the tape comes to rest after a drag, a flick, a tap, or a key. */\n  onValueCommit?: (value: number) => void;\n  min?: number;\n  max?: number;\n  /** The value of one small tick. The tape always rests on a tick. */\n  step?: number;\n  /** PageUp, PageDown, and Shift with an arrow move this far. Defaults to one labelled tick. */\n  largeStep?: number;\n  /** Small ticks between labelled ticks. */\n  majorEvery?: number;\n  /** Pixels between small ticks. Wider spacing gives finer control. */\n  spacing?: number;\n  /** Formats the readout, the tick labels, and the spoken value. */\n  format?: (value: number) => string;\n  /** Unit shown after the readout and spoken with the value, such as \"cm\". */\n  unit?: string;\n  /** Submits the value with a form. */\n  name?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Drag = { pointer: number; x: number; from: number; moved: boolean; samples: { t: number; v: number }[] };\n\nconst { spring } = motionTokens;\n/** Motion drops velocity on time-defined springs, so a key or tap that retargets mid flight runs the same spring 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 }; };\nconst glideSpring = physical(spring.snappy);\n/** Pixels the tape may be pulled past either end, and how far a tap may wander before it counts as a drag. */\nconst STRETCH = 44, TAP_SLOP = 4;\n/** Flick momentum: a scroll-like throw (Apple's .998 deceleration projects about half a second of travel) that eases onto a tick. */\nconst POWER = .42, TIME_CONSTANT = 260;\nconst rubber = (distance: number, limit = STRETCH) => Math.sign(distance) * (1 - 1 / (Math.abs(distance) * .55 / limit + 1)) * limit;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst decimalsOf = (value: number) => (String(value).split(\".\")[1] ?? \"\").length;\nconst isDigit = (char: string) => char >= \"0\" && char <= \"9\";\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\n/** Digits keyed by place value from the right, so a column keeps its identity as the number grows and each one rolls on its own. A column\n * that appears or leaves (99.5 to 100.0) opens or closes its width on a spring, so the centered readout never jumps a digit in one frame. */\nfunction Rolling({ text, reduced }: { text: string; reduced: boolean }) {\n  const chars = [...text];\n  let place = chars.filter(isDigit).length, run = 0;\n  const transition = reduced ? { duration: 0 } : spring.morph;\n  return <span className={styles.rolling}><AnimatePresence initial={false}>{chars.map(char => {\n    const digit = isDigit(char);\n    const key = digit ? `d${--place}` : `s${place}-${run++}`;\n    if (digit) run = 0;\n    return <motion.span key={key} className={digit ? styles.column : styles.symbol} initial={{ width: 0, opacity: 0 }} animate={{ width: \"auto\", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={transition}>\n      {digit ? <><span className={styles.sizer}>0</span><span className={styles.strip} style={{ \"--digit\": Number(char) } as CSSProperties}>{DIGITS.map(value => <span key={value}>{value}</span>)}</span></> : char}\n    </motion.span>;\n  })}</AnimatePresence></span>;\n}\n\n/**\n * A precise value input shaped like a tape measure. A ruler of ticks slides under a fixed needle: drag it 1:1, flick it and it coasts\n * with momentum onto a tick, pull it past either end and it rubber-bands back, or tap a spot to glide it there. The readout rolls each\n * digit as the tape passes. Arrows step one tick, Shift+arrows and PageUp or PageDown move one labelled tick, Home and End go to the ends,\n * and horizontal trackpad scrolling moves the tape.\n */\nexport function RulerSlider({ label, value, defaultValue, onValueChange, onValueCommit, min = 0, max = 100, step: stepProp = 1, largeStep, majorEvery = 10, spacing = 10, format, unit, name, disabled, className }: RulerSliderProps) {\n  const reduced = useReducedMotion() ?? false;\n  const labelId = useId();\n  const step = stepProp > 0 ? stepProp : 1;\n  const decimals = Math.max(decimalsOf(step), decimalsOf(min));\n  const perUnit = spacing / step;\n  const snap = (input: number) => clamp(Number((min + Math.round((input - min) / step) * step).toFixed(decimals)), min, max);\n  const [internal, setInternal] = useState(() => snap(defaultValue ?? min));\n  const current = value === undefined ? internal : snap(value);\n  const formatValue = format ?? ((input: number) => input.toLocaleString(\"en-US\", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }));\n  const large = largeStep ?? step * majorEvery;\n\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const latest = useRef(current);\n  const drag = useRef<Drag | null>(null);\n  const flight = useRef<AnimationPlaybackControls | null>(null);\n  const coasting = useRef(false);\n  const wheelTimer = useRef<number | undefined>(undefined);\n  const [dragging, setDragging] = useState(false);\n  // `pos` is the value under the needle as a float; the tape is drawn from it, and whole ticks are committed as they pass the needle.\n  const pos = useMotionValue(current);\n  const x = useTransform(pos, at => -(at - min) * perUnit);\n\n  const emit = (next: number) => {\n    if (latest.current === next) return;\n    latest.current = next;\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n  const stop = () => { flight.current?.stop(); flight.current = null; coasting.current = false; };\n  /** Lands the tape on a value. Keys and taps glide on a spring that keeps any speed already in the tape. */\n  function glideTo(next: number, commit = true) {\n    stop();\n    emit(next);\n    if (reduced) pos.jump(next);\n    else flight.current = animate(pos, next, { ...glideSpring, velocity: pos.getVelocity(), restDelta: step / 200, restSpeed: step / 20 });\n    if (commit) onValueCommit?.(next);\n  }\n\n  // A value from outside (a reset, a linked field) moves the tape there. Values this component emitted are already under the needle.\n  useLayoutEffect(() => {\n    if (current === latest.current) return;\n    latest.current = current;\n    stop();\n    if (reduced) pos.jump(current); else flight.current = animate(pos, current, glideSpring);\n  });\n  // While the tape coasts after a flick, every tick it lands under the needle is the new value, so the readout rolls with it.\n  useEffect(() => pos.on(\"change\", at => { if (coasting.current && !drag.current) emit(snap(at)); }));\n  useEffect(() => () => { stop(); window.clearTimeout(wheelTimer.current); }, []);\n\n  // Horizontal trackpad scrolling moves the tape; vertical scrolling is left to the page.\n  useEffect(() => {\n    const node = viewportRef.current;\n    if (!node || disabled) return;\n    const onWheel = (event: WheelEvent) => {\n      if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;\n      event.preventDefault();\n      stop();\n      const next = clamp(pos.get() + event.deltaX / perUnit, min, max);\n      pos.set(next);\n      emit(snap(next));\n      window.clearTimeout(wheelTimer.current);\n      wheelTimer.current = window.setTimeout(() => glideTo(snap(pos.get())), 120);\n    };\n    node.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => node.removeEventListener(\"wheel\", onWheel);\n  });\n\n  function onPointerDown(event: PointerEvent<HTMLDivElement>) {\n    if (disabled || event.button !== 0 || !event.isPrimary) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    event.currentTarget.focus({ preventScroll: true });\n    // Grabbing a coasting tape catches it where it is, like a finger on a spinning wheel.\n    stop();\n    drag.current = { pointer: event.pointerId, x: event.clientX, from: pos.get(), moved: false, samples: [{ t: event.timeStamp, v: pos.get() }] };\n  }\n  function onPointerMove(event: PointerEvent<HTMLDivElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    const dx = event.clientX - state.x;\n    if (!state.moved) { if (Math.abs(dx) < TAP_SLOP) return; state.moved = true; setDragging(true); }\n    const raw = state.from - dx / perUnit;\n    const edge = clamp(raw, min, max);\n    const placed = reduced ? edge : edge + rubber((raw - edge) * perUnit) / perUnit;\n    pos.set(placed);\n    state.samples.push({ t: event.timeStamp, v: placed });\n    if (state.samples.length > 6) state.samples.shift();\n    emit(snap(edge));\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    setDragging(false);\n    if (!state.moved) {\n      // A tap glides the tape so the tapped tick comes to the needle.\n      if (event.type !== \"pointerup\") return;\n      const rect = viewportRef.current?.getBoundingClientRect();\n      if (rect) glideTo(snap(pos.get() + (event.clientX - (rect.left + rect.width / 2)) / perUnit));\n      return;\n    }\n    const samples = state.samples, first = samples[0]!, last = samples[samples.length - 1]!;\n    const elapsed = (last.t - first.t) / 1000;\n    const velocity = event.type === \"pointerup\" && elapsed > .008 && event.timeStamp - last.t < 60 ? (last.v - first.v) / elapsed : 0;\n    if (reduced) { const next = snap(pos.get()); pos.jump(next); emit(next); onValueCommit?.(next); return; }\n    const release = pos.get();\n    const done = () => { flight.current = null; coasting.current = false; const next = snap(pos.get()); emit(next); onValueCommit?.(next); };\n    coasting.current = true;\n    if (release < min || release > max) {\n      // Past an end the band pulls the tape home with the speed it was thrown at.\n      flight.current = animate(pos, clamp(release, min, max), { ...physical({ visualDuration: .5, bounce: .1 }), velocity, onComplete: done });\n      return;\n    }\n    // The target is where the throw lands; Motion skips an animation whose target equals where it starts.\n    flight.current = animate(pos, snap(release + POWER * velocity), { type: \"inertia\", velocity, power: POWER, timeConstant: TIME_CONSTANT, modifyTarget: target => snap(target), min, max, bounceStiffness: 420, bounceDamping: 36, restDelta: step / 200, onComplete: done });\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (disabled) return;\n    const now = latest.current;\n    const moves: Record<string, number> = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: large, PageDown: -large };\n    let wanted: number;\n    if (event.key === \"Home\") wanted = min;\n    else if (event.key === \"End\") wanted = max;\n    else if (event.key in moves) wanted = now + (event.shiftKey && event.key.startsWith(\"Arrow\") ? Math.sign(moves[event.key]!) * large : moves[event.key]!);\n    else return;\n    event.preventDefault();\n    const next = snap(wanted);\n    if (next === now) return;\n    glideTo(next);\n  }\n\n  const text = formatValue(current);\n  const majorStep = step * majorEvery;\n  // Tick labels only carry the decimals their own spacing needs, so a half-kilo scale reads 30, 35, 40.\n  const labelFormat = format ?? ((input: number) => input.toLocaleString(\"en-US\", { maximumFractionDigits: decimalsOf(Number(majorStep.toFixed(decimals))) }));\n  const majors = Array.from({ length: Math.floor((max - min) / majorStep + 1e-9) + 1 }, (_, index) => Number((min + index * majorStep).toFixed(decimals)));\n  const tapeStyle = { \"--spacing\": `${spacing}px`, \"--major\": `${spacing * majorEvery}px`, \"--half\": majorEvery % 2 === 0 ? `${(spacing * majorEvery) / 2}px` : \"0px\", width: (max - min) * perUnit + 1 } as CSSProperties;\n\n  return <div className={[styles.root, className].filter(Boolean).join(\" \")} data-disabled={disabled || undefined} data-dragging={dragging || undefined}>\n    <span id={labelId} className={styles.label}>{label}</span>\n    <div className={styles.readout} aria-hidden=\"true\"><Rolling text={text} reduced={reduced} />{unit && <span className={styles.unit}>{unit}</span>}</div>\n    <div ref={viewportRef} className={styles.viewport} role=\"slider\" tabIndex={disabled ? -1 : 0} aria-labelledby={labelId} aria-valuemin={min} aria-valuemax={max} aria-valuenow={current} aria-valuetext={unit ? `${text} ${unit}` : text} aria-orientation=\"horizontal\" aria-disabled={disabled || undefined}\n      onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd} onLostPointerCapture={onPointerEnd} onKeyDown={onKeyDown}>\n      <div className={styles.fade}>\n        <motion.div className={styles.tape} style={{ ...tapeStyle, x }}>\n          <span className={styles.ticks} />\n          {majors.map((mark, index) => <span key={mark} className={styles.tickLabel} style={{ left: index * spacing * majorEvery }}>{labelFormat(mark)}</span>)}\n        </motion.div>\n      </div>\n      <span className={styles.needle} aria-hidden=\"true\" />\n    </div>\n    {name && <input type=\"hidden\" name={name} value={current} disabled={disabled} />}\n  </div>;\n}\n\nexport default RulerSlider;\n"
    },
    {
      "path": "registry/components/ruler-slider/ruler-slider.module.css",
      "type": "registry:component",
      "target": "@components/xzero/ruler-slider/ruler-slider.module.css",
      "content": "/* Set --ruler-height to resize. Tick spacing and label rhythm come from the `spacing` and `majorEvery` props. */\n.root { --ruler-height: 4.5rem; --tick: color-mix(in oklab, var(--foreground) 20%, transparent); --tick-major: color-mix(in oklab, var(--foreground) 48%, transparent); display: grid; width: 100%; min-width: 0; justify-items: center; gap: var(--space-1); 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.label { color: var(--text-secondary); }\n.readout { display: inline-flex; align-items: baseline; gap: .3em; margin-bottom: var(--space-3); font-size: var(--text-2xl); font-weight: 500; line-height: 1.1; font-variant-numeric: tabular-nums; }\n.unit { color: var(--text-secondary); font-size: var(--text-base); }\n/* The tape is the whole control: drag, flick, or tap it. Vertical pans still scroll the page on touch. */\n.viewport { position: relative; width: 100%; height: var(--ruler-height); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); cursor: grab; outline: none; touch-action: pan-y; -webkit-tap-highlight-color: transparent; }\n.root[data-dragging] .viewport { cursor: grabbing; }\n/* Ticks fade toward the edges, so the eye stays on the needle and the tape reads as continuing past view. */\n.fade { position: absolute; inset: 0; mask-image: linear-gradient(to right, transparent, #000 22%, #000 78%, transparent); }\n/* The tape starts at the needle, offset half a tick so a one pixel tick centers exactly under it. */\n.tape { position: absolute; top: 0; bottom: 0; left: calc(50% - .5px); will-change: transform; }\n.ticks { position: absolute; top: 0; right: 0; left: 0; height: 1.75rem; background-image: linear-gradient(to right, var(--tick-major) 0 1px, transparent 1px), linear-gradient(to right, var(--tick) 0 1px, transparent 1px), linear-gradient(to right, var(--tick) 0 1px, transparent 1px); background-repeat: repeat-x; background-size: var(--major) 1.75rem, var(--half) 1.25rem, var(--spacing) .75rem; }\n.tickLabel { position: absolute; top: 2.25rem; translate: -50% 0; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; white-space: nowrap; pointer-events: none; }\n.needle { position: absolute; top: 0; left: calc(50% - 1px); width: 2px; height: 2.25rem; border-radius: 0 0 var(--radius-pill) var(--radius-pill); background: var(--accent); pointer-events: none; }\n.root[data-disabled] { opacity: .5; }\n.root[data-disabled] .viewport { cursor: not-allowed; }\n/* Rolling digits: each column is a strip of 0 to 9 that slides to its digit on a transition, so fast changes retarget instead of restarting. */\n.rolling { display: inline-flex; }\n.column { position: relative; display: inline-block; height: 1.1em; overflow: hidden; mask-image: linear-gradient(to bottom, transparent, #000 10%, #000 90%, transparent); }\n.sizer { display: block; visibility: hidden; }\n.strip { position: absolute; top: 0; right: 0; left: 0; display: flex; flex-direction: column; text-align: center; transform: translateY(calc(var(--digit) * -1.1em)); transition: transform var(--duration-standard) var(--ease-standard); }\n.strip > span { height: 1.1em; }\n.symbol { display: inline-block; overflow: hidden; white-space: pre; }\n@media (prefers-reduced-motion: reduce) { .strip { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/ruler-slider",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/ruler-slider",
    "markdown": "https://ui.xzero.co.uk/components/ruler-slider/markdown"
  }
}