{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-rail",
  "type": "registry:block",
  "title": "Scroll Rail",
  "description": "An instrument-style chapter rail on the edge of a long page: a tick scale with a sprung progress needle, chapter marks whose labels spring out as you arrive, dock-style magnification on hover, and click or drag to scrub.",
  "docs": "Scroll Rail was added to public/vividsites/scroll-rail.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/scroll-rail",
  "categories": [
    "ui",
    "navigation"
  ],
  "files": [
    {
      "path": "components/vividsites/scroll-rail.html",
      "type": "registry:file",
      "target": "public/vividsites/scroll-rail.html",
      "content": "<!--\nvs-scroll-rail: an instrument-style chapter rail for long pages. A fine tick scale runs down\n  the viewport edge with a lit fill and a needle that ride the scroll on a spring (with a\n  000–100 readout). Chapter marks sit where each chapter begins; the active one's label\n  springs out, the counter rolls to its number, and hovering the rail reveals every label\n  with a stagger while the ticks under the pointer magnify like a dock. Click a chapter\n  to spring-scroll there, or press and drag along the scale to scrub the page.\n\n  Markup:  <nav class=\"vs-scroll-rail\" aria-label=\"Chapters\"></nav>\n           + any elements with data-rail-label=\"Name\" (the chapters, in page order)\n  Attributes on the nav:\n    data-target   selector of a scroll container; omit to follow the window\n    data-labels   \"hover\" (only the active label until hovered/focused, default) or \"always\"\n    data-side     \"right\" (default) or \"left\"\n    data-ticks    number of minor ticks on the scale (default 48)\n  Keyboard: Tab to the chapters, arrows / Home / End move between them, Enter scrolls.\n  The active link carries aria-current=\"true\".\n  JS: nav.__vsSr.refresh() after adding chapters; nav.__vsSr.go(index).\n  Style with --vs-scroll-rail-accent, -ink, -size, -height, -edge. Reduced motion: no springs.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&display=swap\" rel=\"stylesheet\">\n<nav class=\"vs-scroll-rail\" aria-label=\"Chapters\" data-labels=\"hover\" data-side=\"right\" data-ticks=\"48\"></nav>\n\n<!-- your chapters: anything with data-rail-label, in page order (these four are placeholders) -->\n<section data-rail-label=\"Arrival\" style=\"min-height:100vh\"></section>\n<section data-rail-label=\"Survey\" style=\"min-height:100vh\"></section>\n<section data-rail-label=\"Foundry\" style=\"min-height:100vh\"></section>\n<section data-rail-label=\"Signal\" style=\"min-height:100vh\"></section>\n\n<style>\n.vs-scroll-rail {\n  --vs-scroll-rail-accent: #19c39a;              /* fill, head, active chapter */\n  --vs-scroll-rail-ink: currentColor;            /* ticks and type (inherits your text colour) */\n  --vs-scroll-rail-size: 1rem;                   /* scales the whole rail */\n  --vs-scroll-rail-height: min(76vh, 40em);      /* length of the scale */\n  --vs-scroll-rail-edge: 2.5em;                  /* distance from the viewport edge */\n  --vs-scroll-rail-font: \"IBM Plex Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n\n  position: fixed; z-index: 20; top: 50%; right: var(--vs-scroll-rail-edge); box-sizing: border-box;\n  width: 18em; height: var(--vs-scroll-rail-height); transform: translateY(-50%);\n  font-size: var(--vs-scroll-rail-size); font-family: var(--vs-scroll-rail-font); color: var(--vs-scroll-rail-ink);\n  -webkit-font-smoothing: antialiased; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;\n}\n.vs-scroll-rail[data-side=\"left\"] { right: auto; left: var(--vs-scroll-rail-edge); }\n.vs-scroll-rail *, .vs-scroll-rail *::before { box-sizing: border-box; }\n\n/* the chapter counter above the scale */\n.vs-scroll-rail-count {\n  position: absolute; right: 3em; bottom: calc(100% + 1.4em); display: flex; align-items: baseline; gap: .35em;\n  font-variant-numeric: tabular-nums; line-height: 1; white-space: nowrap;\n}\n.vs-scroll-rail-count b { display: inline-block; font-weight: 400; font-size: 3em; letter-spacing: -.04em; color: var(--vs-scroll-rail-ink); }\n.vs-scroll-rail-count span { display: inline-block; overflow: hidden; padding: .06em 0; }\n.vs-scroll-rail-count small { font-size: .78em; letter-spacing: .14em; opacity: .42; }\n\n.vs-scroll-rail-track { position: absolute; top: 0; bottom: 0; right: 3em; left: 0; }\n.vs-scroll-rail-grip { /* press and drag along the scale to scrub */\n  position: absolute; top: -.6em; bottom: -.6em; right: -1.6em; width: 3.4em; touch-action: none; cursor: ns-resize;\n}\n@media (hover: none) { .vs-scroll-rail { pointer-events: none; } .vs-scroll-rail-grip, .vs-scroll-rail-ch { pointer-events: auto; } }\n.vs-scroll-rail-spine, .vs-scroll-rail-fill { position: absolute; top: 0; bottom: 0; right: 0; width: 1px; }\n.vs-scroll-rail-spine { background: currentColor; opacity: .16; }\n.vs-scroll-rail-fill {\n  width: 2px; margin-right: -.5px; background: var(--vs-scroll-rail-accent); transform-origin: 50% 0; transform: scaleY(0); will-change: transform;\n  box-shadow: 0 0 .6em color-mix(in srgb, var(--vs-scroll-rail-accent) 55%, transparent);\n}\n.vs-scroll-rail-ticks { position: absolute; inset: 0; pointer-events: none; }\n.vs-scroll-rail-ticks i {\n  position: absolute; right: 0; width: 1em; height: 1px; margin-top: -.5px; background: currentColor; opacity: .26;\n  transform-origin: 100% 50%; will-change: transform;\n}\n.vs-scroll-rail-ticks i.is-past { background: var(--vs-scroll-rail-accent); opacity: .7; }\n\n.vs-scroll-rail-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; pointer-events: none; }\n.vs-scroll-rail-list li { position: absolute; right: 0; top: 0; height: 0; }\n.vs-scroll-rail-ch {\n  position: absolute; right: 0; top: 0; display: flex; align-items: center; gap: .9em; height: 2.2em; margin-top: -1.1em; padding-left: .6em;\n  color: inherit; text-decoration: none; white-space: nowrap; outline: none; pointer-events: auto;\n}\n.vs-scroll-rail-lab {\n  font-size: .8em; letter-spacing: .22em; text-transform: uppercase; opacity: 0; transform-origin: 100% 50%; will-change: transform, opacity;\n}\n.vs-scroll-rail-num { font-size: .72em; letter-spacing: .12em; font-variant-numeric: tabular-nums; opacity: .5; transition: opacity .25s cubic-bezier(.2,0,0,1), color .25s cubic-bezier(.2,0,0,1); }\n.vs-scroll-rail-tk { width: 2.6em; height: 1px; background: currentColor; opacity: .62; transform-origin: 100% 50%; will-change: transform; }\n.vs-scroll-rail-ch.is-active .vs-scroll-rail-num { opacity: 1; color: var(--vs-scroll-rail-accent); }\n.vs-scroll-rail-ch.is-active .vs-scroll-rail-tk { background: var(--vs-scroll-rail-accent); opacity: 1; }\n.vs-scroll-rail-ch.is-past .vs-scroll-rail-tk { opacity: .9; }\n.vs-scroll-rail-ch:hover .vs-scroll-rail-num { opacity: 1; }\n.vs-scroll-rail-ch:focus-visible .vs-scroll-rail-lab { outline: 1px solid var(--vs-scroll-rail-accent); outline-offset: .45em; border-radius: 2px; }\n\n/* the needle that rides the scale, with a percentage readout outside the spine */\n.vs-scroll-rail-head { position: absolute; right: 0; top: 0; width: 0; height: 0; pointer-events: none; will-change: transform; }\n.vs-scroll-rail-head i {\n  position: absolute; right: -.7em; top: -1px; width: 1.4em; height: 2px; border-radius: 2px; background: var(--vs-scroll-rail-accent);\n  box-shadow: 0 0 .8em var(--vs-scroll-rail-accent);\n}\n.vs-scroll-rail-head em {\n  position: absolute; left: 1.05em; top: -.55em; font-style: normal; font-size: .66em; letter-spacing: .08em; line-height: 1.1;\n  color: var(--vs-scroll-rail-accent); font-variant-numeric: tabular-nums;\n}\n\n/* left side: mirror the geometry */\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-count { right: auto; left: 3em; }\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-track { right: 0; left: 3em; }\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-spine, .vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-fill,\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-ticks i, .vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-list li,\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-ch, .vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-head { right: auto; left: 0; }\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-fill { margin: 0 0 0 -.5px; }\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-ticks i, .vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-tk, .vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-lab { transform-origin: 0 50%; }\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-ch { flex-direction: row-reverse; padding: 0 .6em 0 0; }\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-head i { right: auto; left: -.7em; }\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-grip { right: auto; left: -1.6em; }\n.vs-scroll-rail[data-side=\"left\"] .vs-scroll-rail-head em { left: auto; right: 1.05em; }\n\n@media (forced-colors: active) { .vs-scroll-rail-fill, .vs-scroll-rail-head i { background: Highlight; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsScrollRail) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const live = new Set(); let raf = 0, last = 0;\n  const tick = now => {\n    const dt = Math.min(.064, (now - last) / 1000 || .016); last = now;\n    for (const s of live) if (!s.update(dt, now)) live.delete(s);\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = s => { live.add(s); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  /* semi-implicit Euler spring; k stiffness, z damping ratio */\n  const sp = (x, k, z, eps = .001) => ({ x, v: 0, g: x, k, z, eps, at: 0, next: null });\n  const step = (s, h) => { s.v += (-s.k * (s.x - s.g) - 2 * Math.sqrt(s.k) * s.z * s.v) * h; s.x += s.v * h; };\n  const clamp = (x, a, b) => Math.min(b, Math.max(a, x));\n  const pad = n => String(n).padStart(2, '0');\n\n  function mount(nav) {\n    if (nav.__vsSr) return nav.__vsSr;\n    const scroller = nav.dataset.target ? document.querySelector(nav.dataset.target) : null;   // null = the window\n    const root = scroller || document;\n    const chapters = [...root.querySelectorAll('[data-rail-label]')];\n    if (!chapters.length) return null;\n    const top = () => scroller ? scroller.scrollTop : scrollY;\n    const maxTop = () => Math.max(1, scroller ? scroller.scrollHeight - scroller.clientHeight : document.documentElement.scrollHeight - innerHeight);\n    const viewH = () => scroller ? scroller.clientHeight : innerHeight;\n    const setTop = y => scroller ? (scroller.scrollTop = y) : window.scrollTo(0, y);\n    const side = () => nav.dataset.side === 'left' ? -1 : 1;\n    const always = () => nav.dataset.labels === 'always';\n\n    chapters.forEach((c, i) => { if (!c.id) c.id = 'vs-rail-ch-' + (i + 1) + '-' + Math.random().toString(36).slice(2, 6); });\n    if (!nav.hasAttribute('aria-label')) nav.setAttribute('aria-label', 'Chapters');\n    nav.innerHTML =\n      `<div class=\"vs-scroll-rail-count\" aria-hidden=\"true\"><span><b>01</b></span><small>/${pad(chapters.length)}</small></div>` +\n      '<div class=\"vs-scroll-rail-track\"><span class=\"vs-scroll-rail-spine\"></span><span class=\"vs-scroll-rail-grip\" aria-hidden=\"true\"></span><span class=\"vs-scroll-rail-ticks\" aria-hidden=\"true\"></span>' +\n      '<span class=\"vs-scroll-rail-fill\"></span><ol class=\"vs-scroll-rail-list\">' +\n      chapters.map((c, i) => `<li><a class=\"vs-scroll-rail-ch\" href=\"#${c.id}\"><span class=\"vs-scroll-rail-lab\">${c.dataset.railLabel}</span>` +\n        `<span class=\"vs-scroll-rail-num\" aria-hidden=\"true\">${pad(i + 1)}</span><span class=\"vs-scroll-rail-tk\" aria-hidden=\"true\"></span></a></li>`).join('') +\n      '</ol><span class=\"vs-scroll-rail-head\" aria-hidden=\"true\"><i></i><em>000</em></span></div>';\n    const track = nav.querySelector('.vs-scroll-rail-track'), grip = nav.querySelector('.vs-scroll-rail-grip'), fill = nav.querySelector('.vs-scroll-rail-fill');\n    const ticksEl = nav.querySelector('.vs-scroll-rail-ticks'), head = nav.querySelector('.vs-scroll-rail-head'), readout = head.lastChild;\n    const countB = nav.querySelector('.vs-scroll-rail-count b');\n    const items = [...nav.querySelectorAll('.vs-scroll-rail-ch')].map((a, i) => ({\n      a, li: a.parentNode, lab: a.firstChild, num: a.children[1], tk: a.lastChild, tkW: 0, f: 0, top: 0,\n      vis: sp(0, 300, .62), act: sp(0, 260, .55),\n    }));\n    let ticks = [], H = 1, active = -1, hover = false, focusIn = false, scrub = null, glide = null, lastPct = -1;\n    const P = sp(0, 210, .92, .0002);                 // the head / fill\n    const M = sp(0, 260, .9), MY = sp(0, 520, 1, .1);  // magnifier strength + pointer y\n\n    function build() {\n      const n = clamp(parseInt(nav.dataset.ticks, 10) || 48, 4, 240);\n      let h = ''; for (let k = 0; k <= n; k++) h += `<i style=\"top:${(k / n * 100).toFixed(4)}%\"></i>`;\n      ticksEl.innerHTML = h; ticks = [...ticksEl.children].map((el, k) => ({ el, f: k / n }));\n      measure();\n    }\n    function measure() {\n      H = track.clientHeight || 1;\n      const max = maxTop(), base = scroller ? scroller.getBoundingClientRect().top - scroller.scrollTop : -scrollY;\n      items.forEach((it, i) => {\n        it.tkW = it.tk.offsetWidth;\n        it.top = chapters[i].getBoundingClientRect().top - base;\n        it.f = clamp(it.top / max, 0, 1);\n        it.li.style.transform = `translateY(${(it.f * H).toFixed(2)}px)`;\n      });\n      onScroll(true);\n    }\n    function onScroll(silent) {\n      const t = top(), p = clamp(t / maxTop(), 0, 1);\n      P.g = p; if (reduce.matches || silent === true && active < 0) P.x = p;\n      let a = 0; const line = t + viewH() * .12;\n      items.forEach((it, i) => { if (it.top <= line) a = i; });\n      if (t >= maxTop() - 2) a = items.length - 1;\n      if (a !== active) setActive(a);\n      wake(api);\n    }\n    function setActive(a) {\n      const prev = active; active = a;\n      items.forEach((it, i) => {\n        it.a.classList.toggle('is-active', i === a); it.a.classList.toggle('is-past', i < a);\n        if (i === a) it.a.setAttribute('aria-current', 'true'); else it.a.removeAttribute('aria-current');\n      });\n      countB.textContent = pad(a + 1);\n      if (prev >= 0 && !reduce.matches && countB.animate) {\n        const d = a > prev ? 1 : -1;\n        countB.animate([{ transform: `translateY(${d * 70}%)`, opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 520, easing: 'cubic-bezier(.2,1.25,.35,1)' });\n      }\n      reveal();\n    }\n    /* label goals, staggered out from the chapter nearest the pointer */\n    function reveal() {\n      const now = performance.now(), open = hover || focusIn || always();\n      const from = open && hover ? MY.g / H : P.g;\n      items.forEach((it, i) => {\n        const g = i === active ? 1 : open ? (always() && !hover && !focusIn ? .55 : .8) : 0;\n        const d = Math.abs(it.f - from) * items.length;\n        it.vis.next = g; it.vis.at = now + (reduce.matches ? 0 : Math.round(d * (g > it.vis.g ? 34 : 18)));\n        it.act.g = i === active ? 1 : 0;\n      });\n      wake(api);\n    }\n\n    const api = {\n      update(dt, now) {\n        const n = Math.max(1, Math.round(dt * 240)), h = dt / n;\n        let moving = false;\n        if (glide) {                                     // spring-driven scroll to a chapter\n          const g = glide; step(g, dt);\n          if (Math.abs(g.v) < 2 && Math.abs(g.x - g.g) < .5) { setTop(g.g); glide = null; } else { setTop(g.x); moving = true; }\n        }\n        const all = [P, M, MY];\n        for (const it of items) { if (it.vis.next != null && now >= it.vis.at) { it.vis.g = it.vis.next; it.vis.next = null; } if (it.vis.next != null) moving = true; all.push(it.vis, it.act); }\n        for (const s of all) {\n          if (Math.abs(s.v) < s.eps && Math.abs(s.x - s.g) < s.eps) { s.x = s.g; s.v = 0; continue; }\n          moving = true;\n          if (reduce.matches) { s.x = s.g; s.v = 0; continue; }\n          for (let i = 0; i < n; i++) step(s, h);\n        }\n        const dir = side(), p = clamp(P.x, 0, 1);\n        fill.style.transform = `scaleY(${p.toFixed(5)})`;\n        head.style.transform = `translate3d(0,${(p * H).toFixed(2)}px,0)`;\n        const pct = Math.round(P.g * 100);\n        if (pct !== lastPct) { lastPct = pct; readout.textContent = String(pct).padStart(3, '0'); }\n        // dock-style magnification of the ticks under the pointer\n        const m = M.x, my = MY.x, R = Math.max(26, H * .075);\n        for (const t of ticks) {\n          const y = t.f * H, g = m > .002 ? m * Math.exp(-(((y - my) / R) ** 2)) : 0;\n          t.el.style.transform = g ? `scaleX(${(1 + g * 2.1).toFixed(3)})` : '';\n          const past = t.f <= p + 1e-6; if (past !== t.past) { t.past = past; t.el.classList.toggle('is-past', past); }\n        }\n        items.forEach(it => {\n          const y = it.f * H, g = m > .002 ? m * Math.exp(-(((y - my) / R) ** 2)) : 0;\n          const v = clamp(it.vis.x, 0, 1.4), a = it.act.x;\n          it.lab.style.opacity = clamp(v, 0, 1).toFixed(3);\n          const grow = a * .75 + g * .7, ext = it.tkW * grow;       // the tick grows; the numeral and label make room\n          it.lab.style.transform = `translate3d(${(dir * ((1 - clamp(v, 0, 1)) * 18 - a * 6 - ext)).toFixed(2)}px,0,0) scale(${(1 + a * .42).toFixed(4)})`;\n          it.num.style.transform = ext > .05 ? `translate3d(${(-dir * ext).toFixed(2)}px,0,0)` : '';\n          it.tk.style.transform = `scaleX(${(1 + grow).toFixed(4)})`;\n        });\n        return moving || !!scrub;\n      },\n      refresh: () => build(),\n      go: i => go(i),\n    };\n\n    function go(i) {\n      const it = items[i]; if (!it) return;\n      const target = clamp(it.top, 0, maxTop());\n      if (reduce.matches) { setTop(target); return; }\n      glide = glide || { x: top(), v: 0, g: target, k: 90, z: 1 };\n      glide.g = target; glide.x = top();\n      wake(api);\n    }\n    const stopGlide = () => { glide = null; };\n    (scroller || window).addEventListener('wheel', stopGlide, { passive: true });\n    (scroller || window).addEventListener('touchstart', stopGlide, { passive: true });\n    (scroller || window).addEventListener('scroll', onScroll, { passive: true });\n\n    items.forEach((it, i) => {\n      it.a.addEventListener('click', e => { e.preventDefault(); go(i); });\n      it.a.addEventListener('keydown', e => {\n        const d = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];\n        if (d == null && e.key !== 'Home' && e.key !== 'End') return;\n        e.preventDefault();\n        const j = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : clamp(i + d, 0, items.length - 1);\n        items[j].a.focus();\n      });\n    });\n    nav.addEventListener('focusin', () => { focusIn = nav.querySelector(':focus-visible') != null; reveal(); });\n    nav.addEventListener('focusout', () => setTimeout(() => { focusIn = nav.contains(document.activeElement) && nav.querySelector(':focus-visible') != null; reveal(); }));\n\n    /* hover: reveal all labels + magnify; press on the scale: scrub */\n    const localY = e => clamp(e.clientY - track.getBoundingClientRect().top, 0, H);\n    nav.addEventListener('pointermove', e => {\n      if (e.pointerType === 'mouse' || scrub) { MY.g = localY(e); if (M.x < .01 && !scrub) MY.x = MY.g; }\n      if (scrub && e.pointerId === scrub.id) { glide = null; setTop(localY(e) / H * maxTop()); }\n      wake(api);\n    });\n    nav.addEventListener('pointerenter', e => { if (e.pointerType !== 'mouse') return; hover = true; MY.x = MY.g = localY(e); M.g = 1; reveal(); });\n    nav.addEventListener('pointerleave', e => { if (e.pointerType !== 'mouse') return; hover = false; M.g = 0; reveal(); });\n    grip.addEventListener('pointerdown', e => {\n      if (e.button > 0) return;\n      scrub = { id: e.pointerId }; MY.x = MY.g = localY(e); M.g = 1;\n      try { grip.setPointerCapture(e.pointerId); } catch (_) {}\n      const f = localY(e) / H;                         // jump there on a spring, then follow the finger\n      glide = { x: top(), v: 0, g: f * maxTop(), k: 140, z: 1 }; wake(api);\n    });\n    const endScrub = e => { if (!scrub || e.pointerId !== scrub.id) return; scrub = null; if (!hover) M.g = 0; wake(api); };\n    grip.addEventListener('pointerup', endScrub); grip.addEventListener('pointercancel', endScrub);\n\n    new MutationObserver(ms => { if (ms.some(m => m.attributeName === 'data-ticks')) build(); reveal(); })\n      .observe(nav, { attributes: true, attributeFilter: ['data-ticks', 'data-labels', 'data-side'] });\n    const ro = new ResizeObserver(() => measure());\n    ro.observe(track); chapters.forEach(c => ro.observe(c));\n    nav.__vsSr = api;\n    build();\n    items.forEach((it, i) => { it.vis.x = it.vis.g = i === active ? 1 : always() ? .55 : 0; it.act.x = it.act.g = i === active ? 1 : 0; });\n    P.x = P.g; api.update(0, performance.now());\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-scroll-rail').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsScrollRail = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Navigation",
    "tags": [
      "scroll",
      "progress",
      "table of contents",
      "chapters",
      "navigation",
      "hud",
      "spring"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": "wetstreets",
    "free": true,
    "accent": "#5ff2c4",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/scroll-rail/index.html",
    "thumbnail": "https://vividsites.app/api/component/scroll-rail/thumb.jpg",
    "video": "https://vividsites.app/api/component/scroll-rail/thumb.mp4",
    "page": "https://vividsites.app/c/scroll-rail"
  }
}