{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"range-slider-ruler","type":"registry:component","title":"Range Slider Ruler Slider","description":"The needle stays put and the scale scrolls under it. A flick keeps going and settles on the nearest tick. Fractional steps read at the step's own precision.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/range-slider-ruler.tsx","type":"registry:component","target":"@components/motion/range-slider-ruler.tsx","content":"\"use client\";\n// beui.dev/components/motion/range-slider\n\nimport { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useRef } from \"react\";\n\nimport { type SliderOptions, snapSliderValue, useSlider } from \"@/lib/hooks/use-slider\";\nimport { TOUCH_GESTURE_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// Settle spring for the snap after a flick — quick, no overshoot past the tick.\nconst SPRING_SNAP = { type: \"spring\", stiffness: 500, damping: 40, mass: 0.6 } as const;\n\nexport interface RulerSliderProps extends SliderOptions {\n  /** Pixels between two steps. */\n  gap?: number;\n  /** Label every Nth step; those ticks are drawn tall. */\n  majorEvery?: number;\n  /** Unit shown next to the value. */\n  unit?: string;\n  className?: string;\n}\n\n/**\n * Ruler slider: the scale scrolls under a fixed needle instead of a handle\n * moving along a track. Flicks carry momentum and settle onto the nearest tick.\n */\nexport function RulerSlider({\n  gap = 14,\n  majorEvery = 5,\n  unit,\n  className,\n  ...options\n}: RulerSliderProps) {\n  const reduce = useReducedMotion();\n  // Decimal places the step implies, so 0.5 reads \"72.5\" and 1 reads \"72\".\n  // Fixed width keeps the readout from jittering as the value rolls; tick\n  // labels stay trimmed so a whole-number scale is not littered with \".0\".\n  // ponytail: reads 0 decimals for an exponential step (1e-7) — no such scale\n  // is legible on a ruler anyway, so no parsing beyond this.\n  const decimals = String(options.step ?? 1).split(\".\")[1]?.length ?? 0;\n  const readout = (value: number) => value.toFixed(decimals);\n\n  const { current, min, max, step, commit, sliderProps } = useSlider({\n    ...options,\n    // \"72.5 kg\" beats a bare \"72.5\" for a screen reader — but a caller who\n    // formats the announcement itself outranks the unit.\n    formatValueText:\n      options.formatValueText ?? (unit ? (v) => `${readout(v)} ${unit}` : undefined),\n  });\n\n  // The range need not divide by the step (0–10 by 4). Full ticks stop at the\n  // last whole one and max gets a tick of its own, so the scale never runs past\n  // the value the slider can actually report.\n  const span = Number(((max - min) / step).toFixed(6));\n  const wholeSteps = Math.floor(span);\n  const remainder = span - wholeSteps;\n  const maxOffset = span * gap;\n  const x = useMotionValue(-((current - min) / step) * gap);\n  // While the pointer drives the strip (or its momentum still runs), x owns the\n  // value; outside of that the value owns x.\n  const interacting = useRef(false);\n  // True only while the pointer is down. It keeps a cancelled momentum's\n  // transition end from snapping underneath a fresh grab.\n  const holding = useRef(false);\n  // A new gesture or key press bumps this, so a snap that resolves late cannot\n  // clear interacting underneath an active drag.\n  const gesture = useRef(0);\n\n  // ponytail: every tick is in the DOM — fine to a few hundred (80 units at\n  // step 0.5 is 161). Window to the visible span if a finer step is ever needed.\n  // Each tick carries an offset because max sits `remainder` of a step past the\n  // last whole tick. Whenever remainder is under 0.5 that point falls inside\n  // the previous box, so an appended flex box can never centre on it.\n  const ticks = Array.from({ length: wholeSteps + 1 }, (_, i) => ({\n    // toFixed trims float dust from fractional steps (0.1 + 0.2 …).\n    value: Number((min + i * step).toFixed(6)),\n    major: i % majorEvery === 0,\n    offset: i * gap,\n  }));\n  // A tiny remainder puts this label close to the one before it. That is what\n  // a scale ending a hair past a step looks like.\n  if (remainder > 0) ticks.push({ value: max, major: true, offset: maxOffset });\n\n  const snapToTick = () => {\n    // The same nearest-tick rule useSlider applies. max counts as a candidate\n    // when the step does not divide the range, so a flick near the end does\n    // not settle on the last whole step.\n    const target = snapSliderValue(min + (-x.get() / gap) * step, min, max, step);\n    const snapped = -((target - min) / step) * gap;\n    const id = ++gesture.current;\n    if (reduce) {\n      x.set(snapped);\n      interacting.current = false;\n      return;\n    }\n    animate(x, snapped, SPRING_SNAP).then(() => {\n      if (gesture.current === id) interacting.current = false;\n    });\n  };\n\n  // A key press takes the scale back from momentum: without this the coasting\n  // strip keeps committing its own value and swallows the keyboard input.\n  const rootProps = {\n    ...sliderProps,\n    onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {\n      x.stop();\n      gesture.current++;\n      interacting.current = false;\n      holding.current = false;\n      sliderProps.onKeyDown(event);\n    },\n  };\n\n  useEffect(() => {\n    if (interacting.current) return;\n    x.set(-((current - min) / step) * gap);\n  }, [current, min, step, gap, x]);\n\n  useMotionValueEvent(x, \"change\", (v) => {\n    if (!interacting.current) return;\n    commit(min + (-v / gap) * step);\n  });\n\n  return (\n    <div\n      {...rootProps}\n      className={cn(\n        \"relative w-full touch-none overflow-hidden\",\n        TOUCH_GESTURE_CLASS,\n        options.disabled\n          ? \"pointer-events-none opacity-50\"\n          : \"cursor-grab active:cursor-grabbing\",\n        \"rounded-2xl outline-none ring-foreground/30 focus-visible:ring-4\",\n        className,\n      )}\n    >\n      <div className=\"pointer-events-none flex items-baseline justify-center gap-1 pt-1 pb-3\">\n        <span className=\"text-3xl font-semibold tabular-nums text-foreground\">\n          {readout(current)}\n        </span>\n        {unit ? <span className=\"text-sm text-muted-foreground\">{unit}</span> : null}\n      </div>\n\n      {/* masked, not overlaid with background-coloured gradients — the fade has\n          to work on any surface the slider is dropped onto */}\n      <div className=\"relative h-12 [mask-image:linear-gradient(to_right,transparent,black_18%,black_82%,transparent)]\">\n        {/* strip — dragged directly, so momentum comes from the drag gesture */}\n        <motion.div\n          drag={options.disabled ? false : \"x\"}\n          dragConstraints={{ left: -maxOffset, right: 0 }}\n          dragElastic={0.03}\n          dragMomentum={!reduce}\n          dragTransition={{ power: 0.22, timeConstant: 320 }}\n          onDragStart={() => {\n            gesture.current++;\n            interacting.current = true;\n            holding.current = true;\n          }}\n          // Momentum end when there is momentum, drag end when there is not.\n          onDragTransitionEnd={() => {\n            if (!holding.current) snapToTick();\n          }}\n          onDragEnd={() => {\n            holding.current = false;\n            if (reduce) snapToTick();\n          }}\n          // The ticks are positioned rather than laid out, so the row needs an\n          // explicit width plus half a gap of slop each side to cover the\n          // whole drag surface.\n          style={{ x, marginLeft: -gap / 2, width: maxOffset + gap }}\n          className=\"absolute inset-y-0 left-1/2\"\n        >\n          {ticks.map((tick) => (\n            // pb reserves the label row, so minor ticks need no spacer node\n            <span\n              key={tick.value}\n              className=\"absolute bottom-0 flex -translate-x-1/2 flex-col items-center pb-[18px]\"\n              style={{ left: tick.offset + gap / 2 }}\n            >\n              <span\n                className={cn(\n                  \"w-px rounded-full\",\n                  // minor ticks at /45 clear the 3:1 non-text floor in both themes\n                  tick.major ? \"h-7 bg-foreground/70\" : \"h-3.5 bg-foreground/45\",\n                )}\n              />\n              {tick.major ? (\n                <span className=\"absolute bottom-0 text-[10px] tabular-nums text-muted-foreground\">\n                  {tick.value}\n                </span>\n              ) : null}\n            </span>\n          ))}\n        </motion.div>\n\n        {/* needle — the read head the scale moves under */}\n        <div className=\"pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2\">\n          <span className=\"block h-9 w-[3px] rounded-full bg-foreground\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/hooks/use-slider.ts","type":"registry:hook","target":"@lib/hooks/use-slider.ts","content":"\"use client\";\n\nimport { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from \"react\";\nimport { capturePointer, releasePointer } from \"@/lib/touch\";\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Nearest legal value on [min, max] for the given step. max counts as a\n * candidate when the step does not divide the range, so a pointer near the end\n * does not snap back onto the last whole step. */\nexport function snapSliderValue(next: number, min: number, max: number, step: number): number {\n  // Neither case has a grid to walk. An empty range has exactly one legal\n  // point, and a non-positive step only needs a clamp, which also keeps the\n  // division below away from zero.\n  if (!(max > min)) return min;\n  if (!(step > 0)) return clamp(next, min, max);\n  const whole = Math.floor(Number(((max - min) / step).toFixed(6)));\n  const lastWhole = Number((min + whole * step).toFixed(6));\n  const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);\n  const snapped =\n    lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;\n  return Number(snapped.toFixed(6));\n}\n\nexport interface SliderOptions {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  /** Announced instead of the raw number — pass one when the value carries a\n   * unit or a suffix (\"72.5 kg\", \"35%\"); a bare number needs no valueText. */\n  formatValueText?: (value: number) => string;\n}\n\n/**\n * Shared value + input plumbing for slider designs: controlled/uncontrolled\n * value, step snapping, pointer-capture drag along a track and arrow-key\n * control. Visuals and motion live in the component; this only owns the number.\n */\nexport function useSlider({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  formatValueText,\n}: SliderOptions) {\n  const trackRef = useRef<HTMLDivElement>(null);\n  const sliderEl = useRef<HTMLElement | null>(null);\n  // The state drives visuals. Move reads this ref instead, so the first\n  // pointermove after pointerdown does not have to wait on a re-render.\n  const draggingRef = useRef(false);\n  const [internal, setInternal] = useState(defaultValue);\n  const [dragging, setDragging] = useState(false);\n  const controlled = value !== undefined;\n  // Collapse inverted or empty ranges and non-positive steps here, so that\n  // percent, ticks and the keyboard maths never divide by zero or walk a\n  // NaN grid.\n  const lo = min;\n  const hi = max > min ? max : min;\n  const stride = step > 0 ? step : 1;\n  const current = clamp(controlled ? value : internal, lo, hi);\n  const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;\n\n  const commit = useCallback(\n    (next: number) => {\n      const clean = snapSliderValue(next, lo, hi, stride);\n      if (!controlled) setInternal(clean);\n      onValueChange?.(clean);\n    },\n    [controlled, onValueChange, lo, hi, stride],\n  );\n\n  const commitFromX = useCallback(\n    (clientX: number) => {\n      const rect = trackRef.current?.getBoundingClientRect();\n      if (!rect || rect.width === 0) return;\n      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);\n      commit(lo + ratio * (hi - lo));\n    },\n    [commit, lo, hi],\n  );\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      // Start the drag first: capture is a convenience, and a browser that\n      // refuses it — or a test DOM that has no pointer capture at all — must\n      // not take the drag down with it.\n      draggingRef.current = true;\n      setDragging(true);\n      capturePointer(event.currentTarget, event.pointerId);\n      // A click on the track should land keyboard focus on the handle.\n      sliderEl.current?.focus({ preventScroll: true });\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (!draggingRef.current || disabled) return;\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {\n    releasePointer(event.currentTarget, event.pointerId);\n    draggingRef.current = false;\n    setDragging(false);\n  }, []);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLElement>) => {\n      if (disabled) return;\n      const map: Record<string, number> = {\n        ArrowRight: current + stride,\n        ArrowUp: current + stride,\n        ArrowLeft: current - stride,\n        ArrowDown: current - stride,\n        PageUp: current + stride * 10,\n        PageDown: current - stride * 10,\n        Home: lo,\n        End: hi,\n      };\n      if (event.key in map) {\n        event.preventDefault();\n        commit(map[event.key]);\n      }\n    },\n    [disabled, current, stride, lo, hi, commit],\n  );\n\n  return {\n    current,\n    percent,\n    dragging,\n    min: lo,\n    max: hi,\n    step: stride,\n    commit,\n    /** Pointer handlers for the track element — drag anywhere on it. */\n    trackProps: {\n      ref: trackRef,\n      onPointerDown,\n      onPointerMove,\n      onPointerUp: endDrag,\n      onPointerCancel: endDrag,\n      onLostPointerCapture: endDrag,\n    },\n    /** ARIA + keyboard props for the focusable slider element. */\n    sliderProps: {\n      // Callback keeps the handle typed across button/div/motion hosts.\n      ref: (node: HTMLElement | null) => {\n        sliderEl.current = node;\n      },\n      role: \"slider\" as const,\n      tabIndex: disabled ? -1 : 0,\n      \"aria-label\": ariaLabel,\n      \"aria-valuemin\": lo,\n      \"aria-valuemax\": hi,\n      \"aria-valuenow\": current,\n      \"aria-valuetext\": formatValueText?.(current),\n      \"aria-disabled\": disabled || undefined,\n      onKeyDown,\n    },\n  };\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}