{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elastic-rail",
  "title": "Elastic Rail",
  "description": "A scroll progress rail that bulges with overscroll and twangs back. It obeys the wave equation, so its pitch follows f = (1/2L)·√(T/μ) — a longer rail rings lower, with nothing tuned per page.",
  "dependencies": [],
  "files": [
    {
      "path": "src/components/ElasticRail/index.js",
      "content": "export { default } from './ElasticRail.jsx'\n",
      "type": "registry:component",
      "target": "components/elastic-rail/index.js"
    },
    {
      "path": "src/components/ElasticRail/ElasticRail.jsx",
      "content": "'use client'\n\nimport { useEffect, useRef } from 'react'\nimport './ElasticRail.css'\n\n/* =========================================================\n   ElasticRail\n   A scroll progress rail that stretches when you push past\n   the end of the page, and twangs back when you let go.\n\n   The rail is a string fixed at both ends. Overscroll plucks\n   it, and it then obeys the wave equation, discretised:\n\n     ẍᵢ = c²(xᵢ₋₁ − 2xᵢ + xᵢ₊₁) − damping·ẋᵢ\n\n   which means the pitch is not a constant someone chose. For\n   a string of length L under tension T with mass per length\n   μ, the fundamental is\n\n     f = (1/2L)·√(T/μ)\n\n   so a longer rail rings lower. On a tall page the rail is\n   long and the twang is slow and deep; on a short one it is\n   short and sharp. Nothing has to be tuned per page — the\n   geometry does it, which is the whole reason to use a\n   string rather than a spring.\n\n   The rail is drawn as a quadratic through the displaced\n   nodes rather than a polyline, so the curve stays smooth at\n   the twenty-odd nodes the simulation actually needs.\n   ========================================================= */\n\nconst clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n)\n\nexport default function ElasticRail({\n  nodes = 24,\n  tension = 5200,      // T/μ, so wave speed is √tension\n  damping = 3.1,\n  give = 0.42,         // px of bulge per px of overscroll\n  thickness = 3,\n  progressColor,\n  trackColor,\n  vertical = false,\n  target,              // element to track; defaults to the page\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const svgRef = useRef(null)\n  const trackRef = useRef(null)\n  const fillRef = useRef(null)\n\n  const live = useRef({})\n  live.current = { tension, damping, give, vertical }\n\n  useEffect(() => {\n    const root = rootRef.current\n    const svg = svgRef.current\n    const track = trackRef.current\n    const fill = fillRef.current\n    if (!root || !svg) return\n\n    const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    const n = Math.max(4, nodes)\n    const y = new Float32Array(n)\n    const v = new Float32Array(n)\n\n    let W = 0, H = 0, L = 0\n    let progress = 0\n    let raf = 0\n    let last = 0\n    let acc = 0\n    const SUB = 1 / 240      // stiff string: small fixed substeps\n\n    const scroller = () => target || document.scrollingElement || document.documentElement\n\n    const measure = () => {\n      const b = root.getBoundingClientRect()\n      W = b.width; H = b.height\n      L = live.current.vertical ? H : W\n      svg.setAttribute('viewBox', `0 0 ${W} ${H}`)\n    }\n\n    const pluck = (amount) => {\n      // A single half-sine, so the string is plucked in its fundamental rather\n      // than kicked into a corner.\n      for (let i = 1; i < n - 1; i++) {\n        v[i] += amount * Math.sin((i / (n - 1)) * Math.PI) * 60\n      }\n    }\n\n    const step = (dt) => {\n      const { tension: T, damping: d } = live.current\n      for (let i = 1; i < n - 1; i++) {\n        const a = T * (y[i - 1] - 2 * y[i] + y[i + 1]) - d * v[i]\n        v[i] += a * dt\n      }\n      for (let i = 1; i < n - 1; i++) y[i] += v[i] * dt\n      y[0] = y[n - 1] = 0\n      v[0] = v[n - 1] = 0\n    }\n\n    const draw = () => {\n      const vert = live.current.vertical\n      const half = (vert ? W : H) / 2\n      let d = ''\n      for (let i = 0; i < n; i++) {\n        const t = i / (n - 1)\n        const along = t * L\n        const off = half + y[i]\n        const x = vert ? off : along\n        const py = vert ? along : off\n        if (!i) { d += `M${x.toFixed(2)},${py.toFixed(2)}` }\n        else {\n          // Quadratic through the midpoint of each pair keeps a coarse node\n          // count looking like a string rather than a chain of segments.\n          const pt = (i - 1) / (n - 1)\n          const pa = pt * L\n          const po = half + y[i - 1]\n          const mx = vert ? (off + po) / 2 : (along + pa) / 2\n          const my = vert ? (along + pa) / 2 : (off + po) / 2\n          d += `Q${(vert ? po : pa).toFixed(2)},${(vert ? pa : po).toFixed(2)} ${mx.toFixed(2)},${my.toFixed(2)}`\n        }\n      }\n      track.setAttribute('d', d)\n      if (fill) {\n        fill.setAttribute('d', d)\n        const len = fill.getTotalLength?.() || 0\n        fill.style.strokeDasharray = `${len}`\n        fill.style.strokeDashoffset = `${len * (1 - progress)}`\n      }\n    }\n\n    const settled = () => {\n      for (let i = 1; i < n - 1; i++) if (Math.abs(v[i]) > 0.4 || Math.abs(y[i]) > 0.05) return false\n      return true\n    }\n\n    const frame = (now) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : SUB\n      last = now\n      acc = Math.min(acc + dt, 0.2)\n      while (acc >= SUB) { step(SUB); acc -= SUB }\n      draw()\n      if (settled()) { y.fill(0); v.fill(0); draw(); raf = 0; last = 0; acc = 0; return }\n      raf = requestAnimationFrame(frame)\n    }\n\n    const kick = () => {\n      if (raf || still) return\n      last = 0\n      raf = requestAnimationFrame(frame)\n    }\n\n    let over = 0\n    const onScroll = () => {\n      const s = scroller()\n      const max = Math.max(1, s.scrollHeight - s.clientHeight)\n      const top = s.scrollTop\n      progress = clamp(top / max, 0, 1)\n\n      // Overscroll is what plucks it: past either end, the excess bulges the\n      // rail and is released as a pluck when the page comes back.\n      const excess = top < 0 ? top : top > max ? top - max : 0\n      const bulge = excess * live.current.give\n      if (Math.abs(bulge) > 0.5) {\n        for (let i = 1; i < n - 1; i++) {\n          y[i] = bulge * Math.sin((i / (n - 1)) * Math.PI)\n        }\n      } else if (Math.abs(over) > 0.5) {\n        pluck(-over * 0.02)\n      }\n      over = bulge\n      draw()\n      kick()\n    }\n\n    const ro = new ResizeObserver(() => { measure(); draw() })\n    ro.observe(root)\n\n    measure()\n    onScroll()\n\n    const s = target || window\n    s.addEventListener('scroll', onScroll, { passive: true })\n\n    return () => {\n      ro.disconnect()\n      s.removeEventListener('scroll', onScroll)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [nodes, target])\n\n  return (\n    <div\n      ref={rootRef}\n      className={`elastic-rail${vertical ? ' is-vertical' : ''} ${className}`.trim()}\n      style={style}\n      role=\"progressbar\"\n      aria-label=\"Reading progress\"\n      {...rest}\n    >\n      <svg ref={svgRef} className=\"elastic-rail__svg\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n        <path ref={trackRef} fill=\"none\" strokeWidth={thickness}\n              stroke={trackColor || 'currentColor'} strokeOpacity=\"0.18\" strokeLinecap=\"round\" />\n        <path ref={fillRef} fill=\"none\" strokeWidth={thickness}\n              stroke={progressColor || 'currentColor'} strokeLinecap=\"round\" />\n      </svg>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/elastic-rail/ElasticRail.jsx"
    },
    {
      "path": "src/components/ElasticRail/ElasticRail.css",
      "content": "/* ElasticRail — the SVG overflows on purpose: the string bulges past the\n   rail's own box when the page is overscrolled. */\n\n.elastic-rail {\n  position: relative;\n  width: 100%;\n  height: 14px;\n  pointer-events: none;\n}\n\n.elastic-rail.is-vertical { width: 14px; height: 100%; }\n\n.elastic-rail__svg {\n  position: absolute;\n  inset: 0;\n  overflow: visible;\n}\n",
      "type": "registry:file",
      "target": "components/elastic-rail/ElasticRail.css"
    },
    {
      "path": "src/components/ElasticRail/ElasticRail.d.ts",
      "content": "import type { ComponentPropsWithoutRef } from 'react'\n\nexport interface ElasticRailProps extends ComponentPropsWithoutRef<'div'> {\n  /** Simulation nodes along the string. */\n  nodes?: number\n  /** T/μ — wave speed is its square root, and pitch follows from that. */\n  tension?: number\n  damping?: number\n  /** Px of bulge per px of overscroll. */\n  give?: number\n  thickness?: number\n  progressColor?: string\n  trackColor?: string\n  vertical?: boolean\n  /** Element to track. Defaults to the page. */\n  target?: HTMLElement | null\n}\n\ndeclare const ElasticRail: (props: ElasticRailProps) => JSX.Element\nexport default ElasticRail\n",
      "type": "registry:file",
      "target": "components/elastic-rail/ElasticRail.d.ts"
    }
  ],
  "type": "registry:component"
}