{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loom-slider",
  "type": "registry:ui",
  "title": "Loom Slider",
  "description": "A row of dashes where the one you are holding stands tallest, with the rise travelling the track as you drag.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/loom-slider.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface LoomSliderProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"onChange\" | \"defaultValue\"\n> {\n  /** The current value. Leave it out to let the slider own it. */\n  value?: number\n  /** Starting value when the slider owns it. */\n  defaultValue?: number\n  /** Called with the value the slider moved to. */\n  onValueChange?: (value: number) => void\n  /** Low end of the range. */\n  min?: number\n  /** High end of the range. */\n  max?: number\n  /** Smallest movement the value can make. */\n  step?: number\n  /** How many dashes are strung across the track. */\n  tickCount?: number\n  /** How many dashes either side of the value the rise reaches across. */\n  reach?: number\n  /** Show the value above the dash it belongs to. */\n  showValue?: boolean\n  /** Turns the value into the text shown above the track. */\n  formatValue?: (value: number) => string\n  /** Name for the slider. */\n  label?: string\n  /** Render at the current value, but refuse input. */\n  disabled?: boolean\n}\n\n/**\n * Dash lengths as a share of the track's height, measured from its middle. The\n * row is close to even on purpose: the rise only has to say where the value is,\n * and a dash that towers over its neighbours turns the track into a chart.\n */\nconst BASE_LENGTH = 30\nconst PEAK_LENGTH = 36\nconst WAVE_LENGTH = 8\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), max)\n\n/** Nearest step from `min`, so a range like 0 to 7 in 0.5s never lands off-grid. */\nfunction snap(value: number, min: number, max: number, step: number) {\n  if (step <= 0) return clamp(value, min, max)\n  return clamp(min + Math.round((value - min) / step) * step, min, max)\n}\n\n/**\n * A bell centred on the value: 1 at the dash being held, 0 once `reach` dashes\n * away. Cosine rather than linear, so the rise has no corner at either end.\n */\nfunction bell(distance: number, reach: number) {\n  if (distance >= reach) return 0\n  return (1 + Math.cos((distance / reach) * Math.PI)) / 2\n}\n\ninterface Motion {\n  /** Where the value is now, on its way to the target. */\n  value: number\n  /** Units per millisecond, which is how hard the wave is driven. */\n  velocity: number\n  /** Milliseconds the slider has been in motion, used as the wave's phase. */\n  elapsed: number\n}\n\n/**\n * Every dash is drawn from this each frame. A CSS transition restarted on every\n * pointer move never arrives, so there is none. `tau` is roughly how long the\n * gap to the target takes to close. Frames are measured, not counted.\n */\nfunction useMotion(target: number, tau: number): Motion {\n  const [motion, setMotion] = React.useState<Motion>({\n    value: target,\n    velocity: 0,\n    elapsed: 0,\n  })\n  const valueRef = React.useRef(target)\n  const elapsedRef = React.useRef(0)\n\n  React.useEffect(() => {\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      valueRef.current = target\n      setMotion({ value: target, velocity: 0, elapsed: elapsedRef.current })\n      return\n    }\n\n    let frame = 0\n    let last = performance.now()\n\n    const tick = (now: number) => {\n      // A tab that was in the background hands back one enormous frame. Capping\n      // it keeps that from registering as a violent flick of the slider.\n      const delta = Math.min(now - last, 64)\n      last = now\n      elapsedRef.current += delta\n\n      const remaining = target - valueRef.current\n      if (Math.abs(remaining) < 0.002) {\n        valueRef.current = target\n        setMotion({ value: target, velocity: 0, elapsed: elapsedRef.current })\n        return\n      }\n\n      const movement = remaining * (1 - Math.exp(-delta / tau))\n      valueRef.current += movement\n      setMotion({\n        value: valueRef.current,\n        velocity: movement / delta,\n        elapsed: elapsedRef.current,\n      })\n      frame = requestAnimationFrame(tick)\n    }\n\n    frame = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(frame)\n  }, [target, tau])\n\n  return motion\n}\n\n/**\n * A row of dashes rather than a bar. The dash on the value is longest and its\n * neighbours fall away behind it, so the grab point reads without a knob.\n * Moving it sends a wave along the row, as strong as the move that caused it.\n */\nexport function LoomSlider({\n  value,\n  defaultValue = 50,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  tickCount = 41,\n  reach = 4,\n  showValue = true,\n  formatValue,\n  label = \"Value\",\n  disabled = false,\n  className,\n  style,\n  ...props\n}: LoomSliderProps) {\n  const [own, setOwn] = React.useState(() => snap(defaultValue, min, max, step))\n  const [dragging, setDragging] = React.useState(false)\n  const trackRef = React.useRef<HTMLDivElement>(null)\n\n  const isControlled = value !== undefined\n  const current = snap(isControlled ? value : own, min, max, step)\n  // Close behind the finger while dragging, unhurried when a key or a click\n  // sends the value somewhere else and the trip is worth watching.\n  const motion = useMotion(current, dragging ? 45 : 110)\n\n  const span = max === min ? 1 : max - min\n  /** Where the value sits along the track, 0 to 1. */\n  const ratio = clamp((motion.value - min) / span, 0, 1)\n  /** How hard the row is being driven, 0 at rest and 1 on a fast drag. */\n  const energy = clamp((Math.abs(motion.velocity) / span) * 900, 0, 1)\n\n  // The rise is centred on a dash, not on the raw ratio. Landing between two\n  // dashes lifts one side harder than the other and leaves no middle to read\n  // the value off, which is the whole job of the peak.\n  const peakIndex = Math.round(ratio * (tickCount - 1))\n  const peak = tickCount > 1 ? peakIndex / (tickCount - 1) : 0\n\n  const commit = (next: number) => {\n    if (disabled) return\n    const snapped = snap(next, min, max, step)\n    if (!isControlled) setOwn(snapped)\n    if (snapped !== current) onValueChange?.(snapped)\n  }\n\n  const commitFromPointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    const track = trackRef.current\n    if (!track) return\n    const rect = track.getBoundingClientRect()\n    const position = (event.clientX - rect.left) / rect.width\n    commit(min + clamp(position, 0, 1) * (max - min))\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const distance = event.shiftKey ? step * 10 : step\n\n    if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\") {\n      commit(current - distance)\n    } else if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\") {\n      commit(current + distance)\n    } else if (event.key === \"PageDown\") {\n      commit(current - step * 10)\n    } else if (event.key === \"PageUp\") {\n      commit(current + step * 10)\n    } else if (event.key === \"Home\") {\n      commit(min)\n    } else if (event.key === \"End\") {\n      commit(max)\n    } else {\n      return\n    }\n\n    event.preventDefault()\n  }\n\n  const ticks = React.useMemo(\n    () => Array.from({ length: tickCount }, (_, index) => index),\n    [tickCount]\n  )\n\n  return (\n    <div\n      data-slot=\"loom-slider\"\n      data-dragging={dragging ? \"\" : undefined}\n      className={cn(\"w-full select-none\", disabled && \"opacity-50\", className)}\n      style={style}\n      {...props}\n    >\n      {showValue ? (\n        <div className=\"relative mb-3 h-7\">\n          <div\n            data-slot=\"loom-slider-value\"\n            className=\"text-foreground absolute top-0 text-2xl leading-none font-semibold tabular-nums\"\n            // Sits over the dash the rise is centred on, so the number and the\n            // tallest dash are never a few pixels out from each other.\n            style={{ left: `${peak * 100}%`, translate: \"-50% 0\" }}\n          >\n            {formatValue\n              ? formatValue(current)\n              : Math.round(motion.value).toLocaleString()}\n          </div>\n        </div>\n      ) : null}\n\n      <div\n        ref={trackRef}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={label}\n        aria-orientation=\"horizontal\"\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={current}\n        aria-valuetext={formatValue?.(current)}\n        aria-disabled={disabled || undefined}\n        onKeyDown={handleKeyDown}\n        onPointerDown={(event) => {\n          if (disabled) return\n          event.currentTarget.setPointerCapture(event.pointerId)\n          setDragging(true)\n          commitFromPointer(event)\n        }}\n        onPointerMove={(event) => {\n          if (dragging) commitFromPointer(event)\n        }}\n        onPointerUp={(event) => {\n          event.currentTarget.releasePointerCapture(event.pointerId)\n          setDragging(false)\n        }}\n        onPointerCancel={() => setDragging(false)}\n        className={cn(\n          \"ring-ring/60 relative flex h-16 items-center justify-between rounded-md outline-none focus-visible:ring-2\",\n          disabled ? \"cursor-not-allowed\" : \"cursor-ew-resize\"\n        )}\n        // Only the axis the value travels on is claimed, so a finger that lands\n        // on the track can still scroll the page.\n        style={{ touchAction: \"pan-y\" }}\n      >\n        {ticks.map((index) => {\n          const position = tickCount > 1 ? index / (tickCount - 1) : 0\n          // A whole number of dashes from the peak, so the same count rises on\n          // either side and `reach` means the same thing however many dashes\n          // are strung across the track.\n          const distance = Math.abs(index - peakIndex)\n          const weight = bell(distance, reach)\n\n          // The wave: one travelling ripple, strongest at the value and dying\n          // out along the row, and only there at all while the value moves.\n          const ripple =\n            energy > 0\n              ? Math.sin(distance * 0.8 - motion.elapsed * 0.013) *\n                Math.exp(-distance / 7) *\n                WAVE_LENGTH *\n                energy\n              : 0\n\n          const length = Math.max(\n            6,\n            BASE_LENGTH + weight * PEAK_LENGTH + ripple\n          )\n\n          return (\n            <span\n              key={index}\n              aria-hidden=\"true\"\n              data-slot=\"loom-slider-tick\"\n              className={cn(\n                \"w-[3px] shrink-0 rounded-full\",\n                index <= peakIndex ? \"bg-primary\" : \"bg-muted-foreground\"\n              )}\n              style={{\n                height: `${length}%`,\n                opacity: clamp(0.45 + weight * 0.55 + energy * 0.1, 0, 1),\n              }}\n            />\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ]
}