{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"range-ruler","type":"registry:component","title":"Range ruler","description":"Two hairline needles across a graduated scale. Thumbs glide without layout thrash and repel each other on contact.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/range-ruler.tsx","type":"registry:component","content":"// Hairline UI · range-ruler · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\n/* ─────────────────────────────────────────────────────────\n * RANGE RULER: dual needles on a graduated hairline ruler\n *\n *   ruler      an axis with 1px SVG tick marks; major ticks\n *              mark intervals, minor ticks subdivide steps\n *   needles    two tactile hairline sliders with 1px indicators;\n *              dragging glides with zero layout thrash\n *   repulsion  drag the lower needle into the upper one and it\n *              pushes the upper needle forward instead of\n *              clipping; drag backwards and it pushes down\n *   badges     floating metric pills roll with NumberRoll;\n *              when needles get within 48px, the badges glide\n *              together into a single merged span\n *   settle     values commit on pointer release, and numbers\n *              roll smoothly to their settled positions\n *\n * Both needles are accessible sliders with full arrow, Home,\n * End, and Page keys.\n * ───────────────────────────────────────────────────────── */\n\nimport * as React from 'react';\nimport { NumberRoll } from './number-roll';\nimport { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { snapRulerValue, useRangeRuler } from './use-range-ruler';\n\nexport type RangeValue = [number, number];\n\nexport interface RangeRulerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'> {\n  label?: string;\n  value?: RangeValue;\n  defaultValue?: RangeValue;\n  min?: number;\n  max?: number;\n  step?: number;\n  minDistance?: number;\n  unit?: string;\n  disabled?: boolean;\n  onValueChange?: (value: RangeValue) => void;\n  onValueCommit?: (value: RangeValue) => void;\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n}\n\nexport function RangeRuler({\n  label,\n  value,\n  defaultValue = [20, 80],\n  min = 0,\n  max = 100,\n  step = 1,\n  minDistance = 0,\n  unit = '',\n  disabled = false,\n  onValueChange,\n  onValueCommit,\n  format,\n  locales,\n  className = '',\n  ...props\n}: RangeRulerProps) {\n  const reduced = useInteractionReducedMotion();\n  const lower = Number.isFinite(min) ? min : 0;\n  const upper = Number.isFinite(max) && max > lower ? max : lower + 100;\n  const increment = Number.isFinite(step) && step > 0 ? step : 1;\n\n  const [own, setOwn] = React.useState<RangeValue>(defaultValue);\n  const currentVal = value ?? own;\n\n  const safeVal: RangeValue = React.useMemo(() => {\n    const l = snapRulerValue(currentVal[0], lower, upper - minDistance, increment);\n    const u = snapRulerValue(currentVal[1], l + minDistance, upper, increment);\n    return [l, u];\n  }, [currentVal, lower, upper, minDistance, increment]);\n\n  const decimals = Math.min(4, Math.max(0, (String(increment).split('.')[1] ?? '').length));\n\n  const formatNumber = React.useCallback(\n    (n: number) => {\n      const formatted = n.toFixed(decimals);\n      return unit ? `${formatted} ${unit}` : formatted;\n    },\n    [decimals, unit]\n  );\n\n  const formatAria = React.useCallback(\n    (n: number) => `${n.toFixed(decimals)}${unit ? ` ${unit}` : ''}`,\n    [decimals, unit]\n  );\n\n  const handleChange = React.useCallback(\n    (next: RangeValue) => onValueChange?.(next),\n    [onValueChange]\n  );\n\n  const handleCommit = React.useCallback(\n    (next: RangeValue) => {\n      if (value === undefined) setOwn(next);\n      onValueCommit?.(next);\n    },\n    [value, onValueCommit]\n  );\n\n  const ruler = useRangeRuler({\n    value: safeVal,\n    min: lower,\n    max: upper,\n    step: increment,\n    minDistance,\n    disabled,\n    reduced,\n    onChange: handleChange,\n    onCommit: handleCommit,\n    format: formatAria,\n    formatNumber,\n  });\n\n  const rangeSpan = upper - lower;\n  const tickCount = Math.min(81, Math.max(11, Math.round(rangeSpan / increment) + 1));\n  const majorInterval = Math.max(1, Math.round(tickCount / 8));\n\n  const ticks = React.useMemo(() => {\n    return Array.from({ length: tickCount }, (_, i) => {\n      const fraction = i / (tickCount - 1);\n      const isMajor = i % majorInterval === 0 || i === tickCount - 1;\n      return { fraction, isMajor, key: i };\n    });\n  }, [tickCount, majorInterval]);\n\n  const lowerHint = React.useId();\n  const upperHint = React.useId();\n\n  return (\n    <div\n      {...props}\n      role=\"group\"\n      aria-label={label ?? 'Range selector'}\n      className={`relative flex w-full max-w-xl select-none flex-col text-foreground ${className}`}\n    >\n      {label && (\n        <div className=\"mb-2 flex items-center justify-between text-[12px] font-medium text-muted-foreground\">\n          <span>{label}</span>\n          <span className=\"font-mono text-[11px] tabular-nums text-muted-foreground/80\">\n            {formatNumber(safeVal[0])} — {formatNumber(safeVal[1])}\n          </span>\n        </div>\n      )}\n\n      {/* Floating Readout Badges */}\n      <div className=\"relative mb-3 h-7 w-full\">\n        {ruler.isMerged ? (\n          <div className=\"absolute inset-x-0 flex items-center justify-center\">\n            <div className=\"inline-flex items-center gap-1 rounded-full border border-border bg-surface px-2.5 py-0.5 text-[11px] font-medium tabular-nums shadow-[0_0_0_1px_var(--border)]\">\n              <span ref={ruler.mergedReadoutRef} style={{ display: ruler.activeThumb ? 'inline' : 'none' }}>\n                {formatNumber(safeVal[0])} – {formatNumber(safeVal[1])}\n              </span>\n              <span style={{ display: ruler.activeThumb ? 'none' : 'inline' }}>\n                <NumberRoll value={safeVal[0]} duration={380} />\n                <span className=\"mx-1 text-muted-foreground\">–</span>\n                <NumberRoll value={safeVal[1]} duration={380} />\n                {unit && <span className=\"ml-1 text-muted-foreground\">{unit}</span>}\n              </span>\n            </div>\n          </div>\n        ) : (\n          <div className=\"relative size-full\">\n            <div\n              className=\"absolute -top-0.5 -translate-x-1/2 transition-[opacity,transform] duration-200\"\n              style={{\n                left: `${((safeVal[0] - lower) / rangeSpan) * 100}%`,\n              }}\n            >\n              <div className=\"inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] font-medium tabular-nums shadow-[0_0_0_1px_var(--border)]\">\n                <span ref={ruler.lowerReadoutRef} style={{ display: ruler.activeThumb ? 'inline' : 'none' }}>\n                  {formatNumber(safeVal[0])}\n                </span>\n                <span style={{ display: ruler.activeThumb ? 'none' : 'inline' }}>\n                  <NumberRoll value={safeVal[0]} duration={380} />\n                  {unit && <span className=\"ml-1 text-muted-foreground\">{unit}</span>}\n                </span>\n              </div>\n            </div>\n\n            <div\n              className=\"absolute -top-0.5 -translate-x-1/2 transition-[opacity,transform] duration-200\"\n              style={{\n                left: `${((safeVal[1] - lower) / rangeSpan) * 100}%`,\n              }}\n            >\n              <div className=\"inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] font-medium tabular-nums shadow-[0_0_0_1px_var(--border)]\">\n                <span ref={ruler.upperReadoutRef} style={{ display: ruler.activeThumb ? 'inline' : 'none' }}>\n                  {formatNumber(safeVal[1])}\n                </span>\n                <span style={{ display: ruler.activeThumb ? 'none' : 'inline' }}>\n                  <NumberRoll value={safeVal[1]} duration={380} />\n                  {unit && <span className=\"ml-1 text-muted-foreground\">{unit}</span>}\n                </span>\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* The Track & Graduated Hairline Ruler */}\n      <div className=\"relative py-2\">\n        {/* SVG Graduated Ruler Ticks */}\n        <div className=\"relative h-6 w-full\" aria-hidden=\"true\">\n          <svg className=\"size-full overflow-visible text-border\" preserveAspectRatio=\"none\">\n            {/* Base Hairline Axis */}\n            <line x1=\"0\" y1=\"23.5\" x2=\"100%\" y2=\"23.5\" stroke=\"currentColor\" strokeWidth=\"1\" />\n            {ticks.map((t) => (\n              <line\n                key={t.key}\n                x1={`${t.fraction * 100}%`}\n                y1={t.isMajor ? 8 : 15}\n                x2={`${t.fraction * 100}%`}\n                y2={23}\n                stroke=\"currentColor\"\n                strokeWidth=\"1\"\n                className={t.isMajor ? 'text-muted-foreground/60' : 'text-border/80'}\n              />\n            ))}\n          </svg>\n        </div>\n\n        {/* Track gesture container */}\n        <div ref={ruler.trackRef} className=\"relative h-6 w-full\">\n          {/* Active Highlight Band */}\n          <div\n            ref={ruler.highlightRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-0 h-full rounded border-y border-primary/40 bg-primary/10\"\n            style={{\n              left: `${((safeVal[0] - lower) / rangeSpan) * 100}%`,\n              width: `${Math.max(0, ((safeVal[1] - safeVal[0]) / rangeSpan) * 100)}%`,\n            }}\n          />\n\n          {/* Lower Needle Thumb */}\n          <button\n            ref={ruler.lowerThumbRef}\n            {...ruler.lowerHandlers}\n            type=\"button\"\n            role=\"slider\"\n            disabled={disabled}\n            aria-label={`Minimum ${label ?? 'value'}`}\n            aria-valuemin={lower}\n            aria-valuemax={safeVal[1] - minDistance}\n            aria-valuenow={safeVal[0]}\n            aria-valuetext={formatAria(safeVal[0])}\n            aria-describedby={lowerHint}\n            className={`absolute -top-1 -ml-3 z-10 flex h-8 w-6 cursor-ew-resize items-center justify-center rounded-md border border-border bg-background touch-none select-none disabled:cursor-default disabled:opacity-40 will-change-transform ${INTERACTION_FOCUS}`}\n            style={{\n              transform: `translateX(${((safeVal[0] - lower) / rangeSpan) * 100}%)`,\n            }}\n          >\n            {/* 1px Vertical Needle */}\n            <span className=\"h-5 w-px bg-primary\" />\n          </button>\n\n          {/* Upper Needle Thumb */}\n          <button\n            ref={ruler.upperThumbRef}\n            {...ruler.upperHandlers}\n            type=\"button\"\n            role=\"slider\"\n            disabled={disabled}\n            aria-label={`Maximum ${label ?? 'value'}`}\n            aria-valuemin={safeVal[0] + minDistance}\n            aria-valuemax={upper}\n            aria-valuenow={safeVal[1]}\n            aria-valuetext={formatAria(safeVal[1])}\n            aria-describedby={upperHint}\n            className={`absolute -top-1 -ml-3 z-10 flex h-8 w-6 cursor-ew-resize items-center justify-center rounded-md border border-border bg-background touch-none select-none disabled:cursor-default disabled:opacity-40 will-change-transform ${INTERACTION_FOCUS}`}\n            style={{\n              transform: `translateX(${((safeVal[1] - lower) / rangeSpan) * 100}%)`,\n            }}\n          >\n            {/* 1px Vertical Needle */}\n            <span className=\"h-5 w-px bg-primary\" />\n          </button>\n        </div>\n\n        {/* Lower / Upper Bound Labels */}\n        <div aria-hidden=\"true\" className=\"mt-2 flex justify-between text-[10px] font-mono tabular-nums text-muted-foreground/70\">\n          <span>{formatNumber(lower)}</span>\n          <span>{formatNumber(upper)}</span>\n        </div>\n      </div>\n\n      <p id={lowerHint} className=\"sr-only\">\n        Adjust minimum. Left arrow decreases, right arrow increases. Shift increases step by 10. Home sets to minimum, End to maximum.\n      </p>\n      <p id={upperHint} className=\"sr-only\">\n        Adjust maximum. Left arrow decreases, right arrow increases. Shift increases step by 10. Home sets to minimum, End to maximum.\n      </p>\n    </div>\n  );\n}\n"},{"path":"registry/use-range-ruler.ts","type":"registry:component","content":"// Hairline UI · range-ruler · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { EASE_OUT } from './ui-interaction-motion';\n\nexport function snapRulerValue(value: number, min: number, max: number, step: number) {\n  if (!Number.isFinite(value) || value <= min) return min;\n  if (value >= max) return max;\n  const snapped = min + Math.round((value - min) / step) * step;\n  return Number(Math.max(min, Math.min(max, snapped)).toFixed(6));\n}\n\ntype Range = [number, number];\n\ntype RangeRulerOptions = {\n  value: Range;\n  min: number;\n  max: number;\n  step: number;\n  minDistance?: number;\n  disabled: boolean;\n  reduced: boolean;\n  onChange?: (value: Range) => void;\n  onCommit?: (value: Range) => void;\n  format: (value: number) => string;\n  formatNumber: (value: number) => string;\n};\n\n/** High-performance gesture controller for a dual-needle graduated ruler. */\nexport function useRangeRuler(options: RangeRulerOptions) {\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const lowerThumbRef = React.useRef<HTMLButtonElement>(null);\n  const upperThumbRef = React.useRef<HTMLButtonElement>(null);\n  const highlightRef = React.useRef<HTMLDivElement>(null);\n  const lowerReadoutRef = React.useRef<HTMLSpanElement>(null);\n  const upperReadoutRef = React.useRef<HTMLSpanElement>(null);\n  const mergedReadoutRef = React.useRef<HTMLSpanElement>(null);\n  const badgeWrapRef = React.useRef<HTMLDivElement>(null);\n\n  const optionsRef = React.useRef(options);\n  optionsRef.current = options;\n\n  const [activeThumb, setActiveThumb] = React.useState<'lower' | 'upper' | null>(null);\n  const [isMerged, setIsMerged] = React.useState(false);\n\n  // Active drag state kept strictly in refs to prevent layout thrash and React re-renders.\n  const gesture = React.useRef<{\n    id: number;\n    thumb: 'lower' | 'upper';\n    startVal: Range;\n    currentVal: Range;\n    trackLeft: number;\n    trackWidth: number;\n  } | null>(null);\n\n  const getRatio = React.useCallback(\n    (val: number) => {\n      const { min, max } = optionsRef.current;\n      return Math.max(0, Math.min(1, (val - min) / (max - min)));\n    },\n    []\n  );\n\n  const paint = React.useCallback(\n    (range: Range, animate: boolean) => {\n      const o = optionsRef.current;\n      const track = trackRef.current;\n      const width = track ? track.offsetWidth : 0;\n      if (!width) return;\n\n      const [lower, upper] = range;\n      const lowerRatio = getRatio(lower);\n      const upperRatio = getRatio(upper);\n\n      const lowerPx = lowerRatio * width;\n      const upperPx = upperRatio * width;\n\n      const transition = animate && !o.reduced ? `transform 360ms ${EASE_OUT}` : 'none';\n      const barTransition = animate && !o.reduced ? `left 360ms ${EASE_OUT}, width 360ms ${EASE_OUT}` : 'none';\n\n      if (lowerThumbRef.current) {\n        lowerThumbRef.current.style.transition = transition;\n        lowerThumbRef.current.style.transform = `translateX(${lowerPx}px)`;\n        lowerThumbRef.current.setAttribute('aria-valuenow', String(lower));\n        lowerThumbRef.current.setAttribute('aria-valuetext', o.format(lower));\n      }\n\n      if (upperThumbRef.current) {\n        upperThumbRef.current.style.transition = transition;\n        upperThumbRef.current.style.transform = `translateX(${upperPx}px)`;\n        upperThumbRef.current.setAttribute('aria-valuenow', String(upper));\n        upperThumbRef.current.setAttribute('aria-valuetext', o.format(upper));\n      }\n\n      if (highlightRef.current) {\n        highlightRef.current.style.transition = barTransition;\n        highlightRef.current.style.left = `${lowerPx}px`;\n        highlightRef.current.style.width = `${Math.max(0, upperPx - lowerPx)}px`;\n      }\n\n      if (lowerReadoutRef.current) lowerReadoutRef.current.textContent = o.formatNumber(lower);\n      if (upperReadoutRef.current) upperReadoutRef.current.textContent = o.formatNumber(upper);\n      if (mergedReadoutRef.current) {\n        mergedReadoutRef.current.textContent = lower === upper ? o.formatNumber(lower) : `${o.formatNumber(lower)} – ${o.formatNumber(upper)}`;\n      }\n\n      const merged = upperPx - lowerPx < 48;\n      setIsMerged(merged);\n    },\n    [getRatio]\n  );\n\n  const commit = React.useCallback(\n    (next: Range, animate = true) => {\n      const o = optionsRef.current;\n      const dist = o.minDistance ?? 0;\n      let lower = snapRulerValue(next[0], o.min, o.max - dist, o.step);\n      let upper = snapRulerValue(next[1], o.min + dist, o.max, o.step);\n\n      if (lower > upper - dist) {\n        if (next[0] > o.value[0]) upper = Math.min(o.max, lower + dist);\n        else lower = Math.max(o.min, upper - dist);\n      }\n\n      const clean: Range = [lower, upper];\n      paint(clean, animate);\n      o.onChange?.(clean);\n      o.onCommit?.(clean);\n    },\n    [paint]\n  );\n\n  const finish = React.useCallback(\n    (cancelled: boolean) => {\n      const g = gesture.current;\n      if (!g) return;\n      gesture.current = null;\n      setActiveThumb(null);\n\n      if (cancelled) {\n        paint(g.startVal, true);\n        optionsRef.current.onChange?.(g.startVal);\n      } else {\n        commit(g.currentVal, false);\n      }\n    },\n    [commit, paint]\n  );\n\n  React.useLayoutEffect(() => {\n    if (!gesture.current) paint(options.value, true);\n  }, [options.value, options.min, options.max, options.reduced, paint]);\n\n  // Handle window resizing to recalibrate positions\n  React.useEffect(() => {\n    const handleResize = () => {\n      if (!gesture.current) paint(optionsRef.current.value, false);\n    };\n    window.addEventListener('resize', handleResize);\n    return () => window.removeEventListener('resize', handleResize);\n  }, [paint]);\n\n  React.useEffect(() => {\n    const interrupted = () => finish(true);\n    const hidden = () => {\n      if (document.hidden) interrupted();\n    };\n    window.addEventListener('blur', interrupted);\n    document.addEventListener('visibilitychange', hidden);\n    return () => {\n      window.removeEventListener('blur', interrupted);\n      document.removeEventListener('visibilitychange', hidden);\n    };\n  }, [finish]);\n\n  React.useEffect(() => {\n    if (options.disabled) finish(true);\n  }, [options.disabled, finish]);\n\n  const startDrag = React.useCallback(\n    (thumb: 'lower' | 'upper', event: React.PointerEvent<HTMLButtonElement>) => {\n      if (event.button !== 0 || !event.isPrimary || optionsRef.current.disabled) return;\n      const track = trackRef.current;\n      if (!track) return;\n\n      const rect = track.getBoundingClientRect();\n      event.currentTarget.setPointerCapture(event.pointerId);\n      event.currentTarget.focus({ preventScroll: true });\n\n      gesture.current = {\n        id: event.pointerId,\n        thumb,\n        startVal: [...optionsRef.current.value] as Range,\n        currentVal: [...optionsRef.current.value] as Range,\n        trackLeft: rect.left,\n        trackWidth: Math.max(1, rect.width),\n      };\n\n      setActiveThumb(thumb);\n      paint(optionsRef.current.value, false);\n    },\n    [paint]\n  );\n\n  const onPointerMove = React.useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      const g = gesture.current;\n      const o = optionsRef.current;\n      if (!g || g.id !== event.pointerId) return;\n\n      const clientX = event.clientX;\n      const fraction = Math.max(0, Math.min(1, (clientX - g.trackLeft) / g.trackWidth));\n      const targetVal = snapRulerValue(o.min + fraction * (o.max - o.min), o.min, o.max, o.step);\n      const dist = o.minDistance ?? 0;\n\n      let [lower, upper] = g.currentVal;\n\n      if (g.thumb === 'lower') {\n        lower = Math.min(o.max - dist, targetVal);\n        // Repulsion physics: lower pushes upper forward\n        if (lower > upper - dist) {\n          upper = Math.min(o.max, lower + dist);\n        }\n      } else {\n        upper = Math.max(o.min + dist, targetVal);\n        // Repulsion physics: upper pushes lower backward\n        if (upper < lower + dist) {\n          lower = Math.max(o.min, upper - dist);\n        }\n      }\n\n      if (lower === g.currentVal[0] && upper === g.currentVal[1]) return;\n\n      g.currentVal = [lower, upper];\n      paint([lower, upper], false);\n      o.onChange?.([lower, upper]);\n    },\n    [paint]\n  );\n\n  const makeKeyHandler = React.useCallback(\n    (thumb: 'lower' | 'upper') => (event: React.KeyboardEvent<HTMLButtonElement>) => {\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        finish(true);\n        return;\n      }\n\n      const o = optionsRef.current;\n      if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key)) return;\n\n      event.preventDefault();\n      const isUp = ['ArrowRight', 'ArrowUp', 'PageUp'].includes(event.key);\n      const mult = event.key.startsWith('Page') || event.shiftKey ? 10 : 1;\n      const delta = o.step * mult * (isUp ? 1 : -1);\n      const dist = o.minDistance ?? 0;\n\n      let [lower, upper] = o.value;\n\n      if (thumb === 'lower') {\n        if (event.key === 'Home') lower = o.min;\n        else if (event.key === 'End') lower = upper - dist;\n        else lower = Math.max(o.min, Math.min(o.max - dist, lower + delta));\n\n        if (lower > upper - dist) upper = Math.min(o.max, lower + dist);\n      } else {\n        if (event.key === 'Home') upper = lower + dist;\n        else if (event.key === 'End') upper = o.max;\n        else upper = Math.max(o.min + dist, Math.min(o.max, upper + delta));\n\n        if (upper < lower + dist) lower = Math.max(o.min, upper - dist);\n      }\n\n      commit([lower, upper]);\n    },\n    [commit, finish]\n  );\n\n  return {\n    trackRef,\n    lowerThumbRef,\n    upperThumbRef,\n    highlightRef,\n    lowerReadoutRef,\n    upperReadoutRef,\n    mergedReadoutRef,\n    badgeWrapRef,\n    activeThumb,\n    isMerged,\n    commit,\n    lowerHandlers: {\n      onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => startDrag('lower', e),\n      onPointerMove,\n      onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(false);\n      },\n      onPointerCancel: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(true);\n      },\n      onLostPointerCapture: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(true);\n      },\n      onBlur: () => finish(true),\n      onKeyDown: makeKeyHandler('lower'),\n    },\n    upperHandlers: {\n      onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => startDrag('upper', e),\n      onPointerMove,\n      onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(false);\n      },\n      onPointerCancel: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(true);\n      },\n      onLostPointerCapture: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(true);\n      },\n      onBlur: () => finish(true),\n      onKeyDown: makeKeyHandler('upper'),\n    },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · range-ruler · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/range-ruler. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}