Range ruler
Two hairline needles across a graduated scale. Thumbs glide without layout thrash and repel each other on contact.
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/range-ruler.jsonOverview
A dual-needle control for intervals and thresholds. Hairline ticks mark intervals along the axis, needles glide with zero pointer latency, and metric pills roll to settled values. When the lower thumb reaches the upper one, it pushes it forward with physical repulsion rather than clipping. Within 48px, badges merge into a single span readout.
States
- glide
- Needles follow pointer movement directly with zero React state updates or layout recalculations.
- repulsion
- Dragging a needle into its sibling pushes the sibling forward or backward, keeping the range valid.
- merge
- When needles come within 48px, two floating metric pills glide together into a single span pill.
- commit
- Pointer release commits values, and NumberRoll rolls each digit to its final position over 380ms.
- cancel
- Escape or pointer interruption restores values from before the gesture without triggering onValueCommit.
Usage
import { RangeRuler } from "@/components/range-ruler";<RangeRuler label="Price interval" min={0} max={500} step={5} unit="$" defaultValue={[50, 250]} onValueCommit={setRange}/>Works with
Filter intervals in an interactive catalog
Keep expensive database or API queries deferred until the gesture completes. The ruler updates its visual needles and text immediately during drag, calling onValueCommit when the user lets go.
"use client";import { useState } from "react";import { RangeRuler } from "@/components/range-ruler";export function CatalogFilter() { const [range, setRange] = useState<[number, number]>([25, 150]); return ( <div className="space-y-4 rounded-xl border border-border p-4"> <RangeRuler label="Target budget" value={range} min={0} max={300} step={5} unit="USD" onValueCommit={(next) => { setRange(next); filterProducts(next); }} /> </div> );}Props
| Prop | Type | Description |
|---|---|---|
| label | string | Accessible label displayed above the ruler. |
| value / defaultValue | [number, number]default [20, 80] | Controlled or initial interval bounds. |
| min / max / step | numberdefault 0 / 100 / 1 | Lower and upper boundaries and step increment. |
| minDistance | numberdefault 0 | Minimum allowed distance between the two needles. |
| unit | string | Optional unit suffix for badges and readouts (e.g. $, ms, MB). |
| onValueChange | (value: [number, number]) => void | Fires during dragging for lightweight live previews. |
| onValueCommit | (value: [number, number]) => void | Fires on pointer release or keyboard adjustment. |
| disabled | booleandefault false | Disables all interactions and cancels any active gesture. |
Accessibility and motion
- Both needles expose native button slider semantics with aria-valuemin, aria-valuemax, and aria-valuenow.
- Arrow keys step by 1 unit; Shift + Arrow or Page Up/Down step by 10 units. Home and End jump to boundary limits.
- Needle transforms and highlight widths are written directly to DOM elements during dragging to prevent React layout thrash.
- Under prefers-reduced-motion, transitions are removed and values settle immediately.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs Number roll.
Source
'use client';/* ───────────────────────────────────────────────────────── * RANGE RULER: dual needles on a graduated hairline ruler * * ruler an axis with 1px SVG tick marks; major ticks * mark intervals, minor ticks subdivide steps * needles two tactile hairline sliders with 1px indicators; * dragging glides with zero layout thrash * repulsion drag the lower needle into the upper one and it * pushes the upper needle forward instead of * clipping; drag backwards and it pushes down * badges floating metric pills roll with NumberRoll; * when needles get within 48px, the badges glide * together into a single merged span * settle values commit on pointer release, and numbers * roll smoothly to their settled positions * * Both needles are accessible sliders with full arrow, Home, * End, and Page keys. * ───────────────────────────────────────────────────────── */import * as React from 'react';import { NumberRoll } from './number-roll';import { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';import { snapRulerValue, useRangeRuler } from './use-range-ruler';export type RangeValue = [number, number];export interface RangeRulerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'> { label?: string; value?: RangeValue; defaultValue?: RangeValue; min?: number; max?: number; step?: number; minDistance?: number; unit?: string; disabled?: boolean; onValueChange?: (value: RangeValue) => void; onValueCommit?: (value: RangeValue) => void; format?: Intl.NumberFormatOptions; locales?: string | string[];}export function RangeRuler({ label, value, defaultValue = [20, 80], min = 0, max = 100, step = 1, minDistance = 0, unit = '', disabled = false, onValueChange, onValueCommit, format, locales, className = '', ...props}: RangeRulerProps) { const reduced = useInteractionReducedMotion(); const lower = Number.isFinite(min) ? min : 0; const upper = Number.isFinite(max) && max > lower ? max : lower + 100; const increment = Number.isFinite(step) && step > 0 ? step : 1; const [own, setOwn] = React.useState<RangeValue>(defaultValue); const currentVal = value ?? own; const safeVal: RangeValue = React.useMemo(() => { const l = snapRulerValue(currentVal[0], lower, upper - minDistance, increment); const u = snapRulerValue(currentVal[1], l + minDistance, upper, increment); return [l, u]; }, [currentVal, lower, upper, minDistance, increment]); const decimals = Math.min(4, Math.max(0, (String(increment).split('.')[1] ?? '').length)); const formatNumber = React.useCallback( (n: number) => { const formatted = n.toFixed(decimals); return unit ? `${formatted} ${unit}` : formatted; }, [decimals, unit] ); const formatAria = React.useCallback( (n: number) => `${n.toFixed(decimals)}${unit ? ` ${unit}` : ''}`, [decimals, unit] ); const handleChange = React.useCallback( (next: RangeValue) => onValueChange?.(next), [onValueChange] ); const handleCommit = React.useCallback( (next: RangeValue) => { if (value === undefined) setOwn(next); onValueCommit?.(next); }, [value, onValueCommit] ); const ruler = useRangeRuler({ value: safeVal, min: lower, max: upper, step: increment, minDistance, disabled, reduced, onChange: handleChange, onCommit: handleCommit, format: formatAria, formatNumber, }); const rangeSpan = upper - lower; const tickCount = Math.min(81, Math.max(11, Math.round(rangeSpan / increment) + 1)); const majorInterval = Math.max(1, Math.round(tickCount / 8)); const ticks = React.useMemo(() => { return Array.from({ length: tickCount }, (_, i) => { const fraction = i / (tickCount - 1); const isMajor = i % majorInterval === 0 || i === tickCount - 1; return { fraction, isMajor, key: i }; }); }, [tickCount, majorInterval]); const lowerHint = React.useId(); const upperHint = React.useId(); return ( <div {...props} role="group" aria-label={label ?? 'Range selector'} className={`relative flex w-full max-w-xl select-none flex-col text-foreground ${className}`} > {label && ( <div className="mb-2 flex items-center justify-between text-[12px] font-medium text-muted-foreground"> <span>{label}</span> <span className="font-mono text-[11px] tabular-nums text-muted-foreground/80"> {formatNumber(safeVal[0])} — {formatNumber(safeVal[1])} </span> </div> )} {/* Floating Readout Badges */} <div className="relative mb-3 h-7 w-full"> {ruler.isMerged ? ( <div className="absolute inset-x-0 flex items-center justify-center"> <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)]"> <span ref={ruler.mergedReadoutRef} style={{ display: ruler.activeThumb ? 'inline' : 'none' }}> {formatNumber(safeVal[0])} – {formatNumber(safeVal[1])} </span> <span style={{ display: ruler.activeThumb ? 'none' : 'inline' }}> <NumberRoll value={safeVal[0]} duration={380} /> <span className="mx-1 text-muted-foreground">–</span> <NumberRoll value={safeVal[1]} duration={380} /> {unit && <span className="ml-1 text-muted-foreground">{unit}</span>} </span> </div> </div> ) : ( <div className="relative size-full"> <div className="absolute -top-0.5 -translate-x-1/2 transition-[opacity,transform] duration-200" style={{ left: `${((safeVal[0] - lower) / rangeSpan) * 100}%`, }} > <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)]"> <span ref={ruler.lowerReadoutRef} style={{ display: ruler.activeThumb ? 'inline' : 'none' }}> {formatNumber(safeVal[0])} </span> <span style={{ display: ruler.activeThumb ? 'none' : 'inline' }}> <NumberRoll value={safeVal[0]} duration={380} /> {unit && <span className="ml-1 text-muted-foreground">{unit}</span>} </span> </div> </div> <div className="absolute -top-0.5 -translate-x-1/2 transition-[opacity,transform] duration-200" style={{ left: `${((safeVal[1] - lower) / rangeSpan) * 100}%`, }} > <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)]"> <span ref={ruler.upperReadoutRef} style={{ display: ruler.activeThumb ? 'inline' : 'none' }}> {formatNumber(safeVal[1])} </span> <span style={{ display: ruler.activeThumb ? 'none' : 'inline' }}> <NumberRoll value={safeVal[1]} duration={380} /> {unit && <span className="ml-1 text-muted-foreground">{unit}</span>} </span> </div> </div> </div> )} </div> {/* The Track & Graduated Hairline Ruler */} <div className="relative py-2"> {/* SVG Graduated Ruler Ticks */} <div className="relative h-6 w-full" aria-hidden="true"> <svg className="size-full overflow-visible text-border" preserveAspectRatio="none"> {/* Base Hairline Axis */} <line x1="0" y1="23.5" x2="100%" y2="23.5" stroke="currentColor" strokeWidth="1" /> {ticks.map((t) => ( <line key={t.key} x1={`${t.fraction * 100}%`} y1={t.isMajor ? 8 : 15} x2={`${t.fraction * 100}%`} y2={23} stroke="currentColor" strokeWidth="1" className={t.isMajor ? 'text-muted-foreground/60' : 'text-border/80'} /> ))} </svg> </div> {/* Track gesture container */} <div ref={ruler.trackRef} className="relative h-6 w-full"> {/* Active Highlight Band */} <div ref={ruler.highlightRef} aria-hidden="true" className="pointer-events-none absolute top-0 h-full rounded border-y border-primary/40 bg-primary/10" style={{ left: `${((safeVal[0] - lower) / rangeSpan) * 100}%`, width: `${Math.max(0, ((safeVal[1] - safeVal[0]) / rangeSpan) * 100)}%`, }} /> {/* Lower Needle Thumb */} <button ref={ruler.lowerThumbRef} {...ruler.lowerHandlers} type="button" role="slider" disabled={disabled} aria-label={`Minimum ${label ?? 'value'}`} aria-valuemin={lower} aria-valuemax={safeVal[1] - minDistance} aria-valuenow={safeVal[0]} aria-valuetext={formatAria(safeVal[0])} aria-describedby={lowerHint} 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}`} style={{ transform: `translateX(${((safeVal[0] - lower) / rangeSpan) * 100}%)`, }} > {/* 1px Vertical Needle */} <span className="h-5 w-px bg-primary" /> </button> {/* Upper Needle Thumb */} <button ref={ruler.upperThumbRef} {...ruler.upperHandlers} type="button" role="slider" disabled={disabled} aria-label={`Maximum ${label ?? 'value'}`} aria-valuemin={safeVal[0] + minDistance} aria-valuemax={upper} aria-valuenow={safeVal[1]} aria-valuetext={formatAria(safeVal[1])} aria-describedby={upperHint} 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}`} style={{ transform: `translateX(${((safeVal[1] - lower) / rangeSpan) * 100}%)`, }} > {/* 1px Vertical Needle */} <span className="h-5 w-px bg-primary" /> </button> </div> {/* Lower / Upper Bound Labels */} <div aria-hidden="true" className="mt-2 flex justify-between text-[10px] font-mono tabular-nums text-muted-foreground/70"> <span>{formatNumber(lower)}</span> <span>{formatNumber(upper)}</span> </div> </div> <p id={lowerHint} className="sr-only"> Adjust minimum. Left arrow decreases, right arrow increases. Shift increases step by 10. Home sets to minimum, End to maximum. </p> <p id={upperHint} className="sr-only"> Adjust maximum. Left arrow decreases, right arrow increases. Shift increases step by 10. Home sets to minimum, End to maximum. </p> </div> );}'use client';import * as React from 'react';import { EASE_OUT } from './ui-interaction-motion';export function snapRulerValue(value: number, min: number, max: number, step: number) { if (!Number.isFinite(value) || value <= min) return min; if (value >= max) return max; const snapped = min + Math.round((value - min) / step) * step; return Number(Math.max(min, Math.min(max, snapped)).toFixed(6));}type Range = [number, number];type RangeRulerOptions = { value: Range; min: number; max: number; step: number; minDistance?: number; disabled: boolean; reduced: boolean; onChange?: (value: Range) => void; onCommit?: (value: Range) => void; format: (value: number) => string; formatNumber: (value: number) => string;};/** High-performance gesture controller for a dual-needle graduated ruler. */export function useRangeRuler(options: RangeRulerOptions) { const trackRef = React.useRef<HTMLDivElement>(null); const lowerThumbRef = React.useRef<HTMLButtonElement>(null); const upperThumbRef = React.useRef<HTMLButtonElement>(null); const highlightRef = React.useRef<HTMLDivElement>(null); const lowerReadoutRef = React.useRef<HTMLSpanElement>(null); const upperReadoutRef = React.useRef<HTMLSpanElement>(null); const mergedReadoutRef = React.useRef<HTMLSpanElement>(null); const badgeWrapRef = React.useRef<HTMLDivElement>(null); const optionsRef = React.useRef(options); optionsRef.current = options; const [activeThumb, setActiveThumb] = React.useState<'lower' | 'upper' | null>(null); const [isMerged, setIsMerged] = React.useState(false); // Active drag state kept strictly in refs to prevent layout thrash and React re-renders. const gesture = React.useRef<{ id: number; thumb: 'lower' | 'upper'; startVal: Range; currentVal: Range; trackLeft: number; trackWidth: number; } | null>(null); const getRatio = React.useCallback( (val: number) => { const { min, max } = optionsRef.current; return Math.max(0, Math.min(1, (val - min) / (max - min))); }, [] ); const paint = React.useCallback( (range: Range, animate: boolean) => { const o = optionsRef.current; const track = trackRef.current; const width = track ? track.offsetWidth : 0; if (!width) return; const [lower, upper] = range; const lowerRatio = getRatio(lower); const upperRatio = getRatio(upper); const lowerPx = lowerRatio * width; const upperPx = upperRatio * width; const transition = animate && !o.reduced ? `transform 360ms ${EASE_OUT}` : 'none'; const barTransition = animate && !o.reduced ? `left 360ms ${EASE_OUT}, width 360ms ${EASE_OUT}` : 'none'; if (lowerThumbRef.current) { lowerThumbRef.current.style.transition = transition; lowerThumbRef.current.style.transform = `translateX(${lowerPx}px)`; lowerThumbRef.current.setAttribute('aria-valuenow', String(lower)); lowerThumbRef.current.setAttribute('aria-valuetext', o.format(lower)); } if (upperThumbRef.current) { upperThumbRef.current.style.transition = transition; upperThumbRef.current.style.transform = `translateX(${upperPx}px)`; upperThumbRef.current.setAttribute('aria-valuenow', String(upper)); upperThumbRef.current.setAttribute('aria-valuetext', o.format(upper)); } if (highlightRef.current) { highlightRef.current.style.transition = barTransition; highlightRef.current.style.left = `${lowerPx}px`; highlightRef.current.style.width = `${Math.max(0, upperPx - lowerPx)}px`; } if (lowerReadoutRef.current) lowerReadoutRef.current.textContent = o.formatNumber(lower); if (upperReadoutRef.current) upperReadoutRef.current.textContent = o.formatNumber(upper); if (mergedReadoutRef.current) { mergedReadoutRef.current.textContent = lower === upper ? o.formatNumber(lower) : `${o.formatNumber(lower)} – ${o.formatNumber(upper)}`; } const merged = upperPx - lowerPx < 48; setIsMerged(merged); }, [getRatio] ); const commit = React.useCallback( (next: Range, animate = true) => { const o = optionsRef.current; const dist = o.minDistance ?? 0; let lower = snapRulerValue(next[0], o.min, o.max - dist, o.step); let upper = snapRulerValue(next[1], o.min + dist, o.max, o.step); if (lower > upper - dist) { if (next[0] > o.value[0]) upper = Math.min(o.max, lower + dist); else lower = Math.max(o.min, upper - dist); } const clean: Range = [lower, upper]; paint(clean, animate); o.onChange?.(clean); o.onCommit?.(clean); }, [paint] ); const finish = React.useCallback( (cancelled: boolean) => { const g = gesture.current; if (!g) return; gesture.current = null; setActiveThumb(null); if (cancelled) { paint(g.startVal, true); optionsRef.current.onChange?.(g.startVal); } else { commit(g.currentVal, false); } }, [commit, paint] ); React.useLayoutEffect(() => { if (!gesture.current) paint(options.value, true); }, [options.value, options.min, options.max, options.reduced, paint]); // Handle window resizing to recalibrate positions React.useEffect(() => { const handleResize = () => { if (!gesture.current) paint(optionsRef.current.value, false); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [paint]); React.useEffect(() => { const interrupted = () => finish(true); const hidden = () => { if (document.hidden) interrupted(); }; window.addEventListener('blur', interrupted); document.addEventListener('visibilitychange', hidden); return () => { window.removeEventListener('blur', interrupted); document.removeEventListener('visibilitychange', hidden); }; }, [finish]); React.useEffect(() => { if (options.disabled) finish(true); }, [options.disabled, finish]); const startDrag = React.useCallback( (thumb: 'lower' | 'upper', event: React.PointerEvent<HTMLButtonElement>) => { if (event.button !== 0 || !event.isPrimary || optionsRef.current.disabled) return; const track = trackRef.current; if (!track) return; const rect = track.getBoundingClientRect(); event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.focus({ preventScroll: true }); gesture.current = { id: event.pointerId, thumb, startVal: [...optionsRef.current.value] as Range, currentVal: [...optionsRef.current.value] as Range, trackLeft: rect.left, trackWidth: Math.max(1, rect.width), }; setActiveThumb(thumb); paint(optionsRef.current.value, false); }, [paint] ); const onPointerMove = React.useCallback( (event: React.PointerEvent<HTMLButtonElement>) => { const g = gesture.current; const o = optionsRef.current; if (!g || g.id !== event.pointerId) return; const clientX = event.clientX; const fraction = Math.max(0, Math.min(1, (clientX - g.trackLeft) / g.trackWidth)); const targetVal = snapRulerValue(o.min + fraction * (o.max - o.min), o.min, o.max, o.step); const dist = o.minDistance ?? 0; let [lower, upper] = g.currentVal; if (g.thumb === 'lower') { lower = Math.min(o.max - dist, targetVal); // Repulsion physics: lower pushes upper forward if (lower > upper - dist) { upper = Math.min(o.max, lower + dist); } } else { upper = Math.max(o.min + dist, targetVal); // Repulsion physics: upper pushes lower backward if (upper < lower + dist) { lower = Math.max(o.min, upper - dist); } } if (lower === g.currentVal[0] && upper === g.currentVal[1]) return; g.currentVal = [lower, upper]; paint([lower, upper], false); o.onChange?.([lower, upper]); }, [paint] ); const makeKeyHandler = React.useCallback( (thumb: 'lower' | 'upper') => (event: React.KeyboardEvent<HTMLButtonElement>) => { if (event.key === 'Escape') { event.preventDefault(); finish(true); return; } const o = optionsRef.current; if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const isUp = ['ArrowRight', 'ArrowUp', 'PageUp'].includes(event.key); const mult = event.key.startsWith('Page') || event.shiftKey ? 10 : 1; const delta = o.step * mult * (isUp ? 1 : -1); const dist = o.minDistance ?? 0; let [lower, upper] = o.value; if (thumb === 'lower') { if (event.key === 'Home') lower = o.min; else if (event.key === 'End') lower = upper - dist; else lower = Math.max(o.min, Math.min(o.max - dist, lower + delta)); if (lower > upper - dist) upper = Math.min(o.max, lower + dist); } else { if (event.key === 'Home') upper = lower + dist; else if (event.key === 'End') upper = o.max; else upper = Math.max(o.min + dist, Math.min(o.max, upper + delta)); if (upper < lower + dist) lower = Math.max(o.min, upper - dist); } commit([lower, upper]); }, [commit, finish] ); return { trackRef, lowerThumbRef, upperThumbRef, highlightRef, lowerReadoutRef, upperReadoutRef, mergedReadoutRef, badgeWrapRef, activeThumb, isMerged, commit, lowerHandlers: { onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => startDrag('lower', e), onPointerMove, onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(false); }, onPointerCancel: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(true); }, onLostPointerCapture: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(true); }, onBlur: () => finish(true), onKeyDown: makeKeyHandler('lower'), }, upperHandlers: { onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => startDrag('upper', e), onPointerMove, onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(false); }, onPointerCancel: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(true); }, onLostPointerCapture: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(true); }, onBlur: () => finish(true), onKeyDown: makeKeyHandler('upper'), }, };}'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export 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';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => { const media = window.matchMedia(query); media.addEventListener('change', notify); return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() { const ref = React.useRef<T>(null); const [visible, setVisible] = React.useState(false); const [hidden, setHidden] = React.useState(false); React.useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); observer.observe(el); const visibility = () => setHidden(document.hidden); visibility(); document.addEventListener('visibilitychange', visibility); return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); }; }, []); return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties { 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}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) { samples.push({ x, time }); while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) { if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0; const first = samples[0], last = samples[samples.length - 1]; return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) { const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping); const displacement = from - to; const coefficient = (velocity * 1000 + damping * omega * displacement) / damped; return Array.from({ length: 33 }, (_, i) => { const t = (i / 32) * duration / 1000; const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t)); return { value: i === 32 ? to : value, offset: i / 32 }; });}Registry item: /r/range-ruler.json