{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-rail",
  "title": "Scroll Rail",
  "author": "Lloyd Humphreys",
  "description": "A dependency-free scroll-position navigation rail: a compact column of ticks pinned to one edge of a scroll container, one per stop, with hover previews and click-to-scroll. Ships a framework-agnostic vanilla core whose observer exposes getState() and subscribe(), plus a React wrapper.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/scroll-rail/scroll-rail.ts",
      "content": "// scroll-rail — a zero-dependency scroll-position navigation rail.\n//\n// A compact column of ticks pinned to one edge of a scroll container, one tick per \"stop\".\n// The tick for the stop you're at is highlighted; hovering/focusing a tick reveals a\n// preview card; clicking a tick smooth-scrolls to it. Modeled on the navigation rail long\n// editor/chat threads use for a long document you can't fit an outline beside.\n//\n// Framework-agnostic vanilla DOM — no dependencies, no build step, works anywhere. A thin\n// React wrapper (<ScrollRail> + useActiveStop) lives in scroll-rail-react.tsx.\n//\n// `observeActive()` is the headless engine underneath both: like the other engines in this\n// registry, it exposes `getState()` + `subscribe()` so more than one consumer (a synced\n// table of contents, analytics) can listen without tearing the observer down and recreating\n// it. `onActiveChange` remains a convenience — sugar for a `subscribe()` call made at\n// construction — for the common single-listener case.\n//\n// ── Theming ────────────────────────────────────────────────────────────────────────────\n// Styles reference CSS custom properties with sensible fallbacks, so it reads correctly in\n// light and dark out of the box: ticks inherit the surrounding text color, and the preview\n// card uses the `Canvas`/`CanvasText` system colors. Override any of these on the rail host\n// (or any ancestor):\n//   --rail-tick          tick color                 (default: currentColor)\n//   --rail-accent        active-tick color          (default: DEFAULT_ACCENT_COLOR, below)\n//   --rail-card-bg       preview card background     (default: Canvas)\n//   --rail-card-fg       preview card text          (default: CanvasText)\n//   --rail-card-border   preview card border color  (default: 20% of text color)\n//   --rail-card-width    preview card width         (default: DEFAULT_CARD_WIDTH, below)\n//\n// ── Positioning ────────────────────────────────────────────────────────────────────────\n// The rail element is `position: absolute`, pinned to the chosen edge and spanning the\n// height of its nearest positioned ancestor. Give the element you append it into (usually\n// the box wrapping your scroll container) `position: relative`.\n\n/** Default `activationOffset`, in px — see `ObserveActiveOptions.activationOffset`. */\nconst DEFAULT_ACTIVATION_OFFSET = 96\n/** Default `previewChars` — see `HeadingItemsOptions.previewChars`. */\nconst DEFAULT_PREVIEW_CHARS = 150\n/** Fallback for `--rail-accent` — see the theming doc above. */\nconst DEFAULT_ACCENT_COLOR = '#3b82f6'\n/** Fallback for `--rail-card-width` — see the theming doc above. */\nconst DEFAULT_CARD_WIDTH = '234px'\n\n/** One stop on the rail. */\nexport interface ScrollRailItem {\n  /** Stable identity — reported by onActiveChange. */\n  id: string\n  /** The element this stop tracks (for active state) and scrolls to on click. */\n  target: HTMLElement\n  /** Accessible label + preview-card title. */\n  label: string\n  /** 1-based depth (1–4); controls tick length. Default 1. */\n  level?: number\n  /** Optional preview-card body. A string, or your own node (cloned on show). */\n  preview?: string | Node\n  /** Optional per-node color (any CSS color) — color-code individual ticks by category,\n   *  status, persona, etc. Overrides `--rail-tick`; a colored tick keeps its color when\n   *  active (instead of falling back to `--rail-accent`). */\n  color?: string\n}\n\n/** Shared options for the active-stop tracker. */\nexport interface ObserveActiveOptions {\n  /** The scrolling element the stops live inside. */\n  scrollContainer: HTMLElement\n  items: ScrollRailItem[]\n  /** A stop is active once its top is within this many px of the container top.\n   *  Default `DEFAULT_ACTIVATION_OFFSET`. */\n  activationOffset?: number\n  /** Called whenever the active stop changes. Sugar for a `subscribe()` call made once at\n   *  construction — for a second listener, call `subscribe()` yourself instead. */\n  onActiveChange?: (id: string | null) => void\n}\n\n/** The observer's state, as reported to `subscribe()`. */\nexport interface ActiveObserverState {\n  activeId: string | null\n}\n\nexport interface ActiveObserver {\n  /** Recompute now (e.g. after a layout change you know about). */\n  refresh(): void\n  setItems(items: ScrollRailItem[]): void\n  getActiveId(): string | null\n  getState(): ActiveObserverState\n  /** Subscribe to active-stop changes; returns unsubscribe. Fires on every change, in\n   *  addition to (not instead of) `onActiveChange`. */\n  subscribe(fn: (state: ActiveObserverState) => void): () => void\n  destroy(): void\n}\n\n/**\n * Headless active-stop tracker (no DOM of its own). The active stop is the last one whose\n * top has scrolled to within `activationOffset` px of the container top; before the first\n * crosses, the first stop is active. This is the engine behind both the vanilla rail and\n * the React `useActiveStop` hook.\n */\nexport function observeActive(opts: ObserveActiveOptions): ActiveObserver {\n  const activationOffset = opts.activationOffset ?? DEFAULT_ACTIVATION_OFFSET\n  let items = opts.items\n  let activeId: string | null = null\n  const subs = new Set<(state: ActiveObserverState) => void>()\n  if (opts.onActiveChange) {\n    const onActiveChange = opts.onActiveChange\n    subs.add((s) => onActiveChange(s.activeId))\n  }\n\n  const getState = (): ActiveObserverState => ({ activeId })\n\n  const notify = () => {\n    const s = getState()\n    subs.forEach((fn) => fn(s))\n  }\n\n  const compute = (): string | null => {\n    if (!items.length) return null\n    const top = opts.scrollContainer.getBoundingClientRect().top\n    let idx = 0\n    items.forEach((it, i) => {\n      if (it.target.getBoundingClientRect().top - top <= activationOffset) idx = i\n    })\n    return items[idx]?.id ?? null\n  }\n\n  const refresh = () => {\n    const next = compute()\n    if (next !== activeId) {\n      activeId = next\n      notify()\n    }\n  }\n\n  const onScroll = () => refresh()\n  opts.scrollContainer.addEventListener('scroll', onScroll, { passive: true })\n  refresh()\n  // Run once more after layout: the first synchronous pass can happen while the targets are\n  // still detached / unlaid-out (every rect reads 0, which would otherwise pin the *last*\n  // stop active until the first scroll).\n  const raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame(refresh) : 0\n\n  return {\n    refresh,\n    setItems(next) { items = next; refresh() },\n    getActiveId: () => activeId,\n    getState,\n    subscribe(fn) {\n      subs.add(fn)\n      return () => subs.delete(fn)\n    },\n    destroy() {\n      opts.scrollContainer.removeEventListener('scroll', onScroll)\n      if (raf && typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(raf)\n      subs.clear()\n    },\n  }\n}\n\nexport interface ScrollRailOptions extends Omit<ObserveActiveOptions, 'onActiveChange'> {\n  /** Which edge to pin to. Default 'right'. */\n  position?: 'left' | 'right'\n  /** Called whenever the active stop changes — e.g. to sync a separate table-of-contents. */\n  onActiveChange?: (id: string | null) => void\n  /** Inject the component stylesheet on first use. Default true; set false to ship the CSS\n   *  yourself (see `railStyles()`). */\n  injectStyles?: boolean\n  /** Extra class(es) added to the rail root, for your own overrides. */\n  className?: string\n}\n\nexport interface ScrollRail {\n  /** The rail root. Append it into a `position: relative` box (usually the one wrapping\n   *  your scroll container). */\n  readonly element: HTMLElement\n  getActiveId(): string | null\n  /** Replace the stops (e.g. after the content changes). */\n  setItems(items: ScrollRailItem[]): void\n  refresh(): void\n  /** Detach listeners. Call before dropping the element; then `element.remove()`. */\n  destroy(): void\n}\n\n/** Build a scroll-position rail. Append `.element` into a positioned box near your scroller. */\nexport function createScrollRail(opts: ScrollRailOptions): ScrollRail {\n  const position = opts.position ?? 'right'\n  if (opts.injectStyles !== false) injectRailStyles()\n\n  const nav = document.createElement('nav')\n  nav.className = `scroll-rail scroll-rail--${position}${opts.className ? ` ${opts.className}` : ''}`\n  nav.setAttribute('aria-label', 'Scroll navigation')\n\n  const track = document.createElement('div')\n  track.className = 'scroll-rail-track'\n\n  const card = document.createElement('div')\n  card.className = 'scroll-rail-card'\n  const cardTitle = document.createElement('div')\n  cardTitle.className = 'scroll-rail-card-title'\n  const cardBody = document.createElement('div')\n  cardBody.className = 'scroll-rail-card-preview'\n  // appendChild (Node), not append: some setups (e.g. Cloudflare Worker types) shadow the\n  // ParentNode.append overload — appendChild is universal and avoids that.\n  card.appendChild(cardTitle)\n  card.appendChild(cardBody)\n\n  nav.appendChild(track)\n  nav.appendChild(card)\n\n  let items: ScrollRailItem[] = []\n  let ticks: HTMLButtonElement[] = []\n\n  const showCard = (i: number) => {\n    const it = items[i]\n    if (!it) return\n    cardTitle.textContent = it.label\n    cardBody.replaceChildren()\n    if (it.preview instanceof Node) {\n      cardBody.appendChild(it.preview.cloneNode(true))\n      cardBody.style.display = ''\n    } else if (it.preview) {\n      cardBody.textContent = it.preview\n      cardBody.style.display = ''\n    } else {\n      cardBody.style.display = 'none'\n    }\n    const navTop = nav.getBoundingClientRect().top\n    const r = ticks[i].getBoundingClientRect()\n    const top = `${r.top + r.height / 2 - navTop}px`\n    if (card.classList.contains('is-visible')) {\n      // Already showing: let `top` transition so the card glides to the new tick.\n      card.style.top = top\n    } else {\n      // Fresh appearance: jump to position (fade in), don't slide from the last spot.\n      card.style.transition = 'none'\n      card.style.top = top\n      void card.offsetHeight // flush the jump before re-enabling transitions\n      card.style.transition = ''\n      card.classList.add('is-visible')\n    }\n  }\n  const hideCard = () => card.classList.remove('is-visible')\n\n  // Hover is driven by the whole rail strip, not the individual 2px ticks: pointing\n  // anywhere in the rail engages the *nearest* tick (so the gaps between ticks no longer\n  // drop the hover), and the preview card glides between ticks instead of blinking off and\n  // on. `hoveredIndex` is the currently engaged tick (-1 for none).\n  let hoveredIndex = -1\n  let lastPointer: { x: number; y: number } | null = null\n  const setHovered = (i: number) => {\n    if (i === hoveredIndex) return\n    if (hoveredIndex >= 0) ticks[hoveredIndex]?.classList.remove('is-hovered')\n    hoveredIndex = i\n    nav.classList.toggle('is-hover', i >= 0)\n    if (i >= 0) {\n      ticks[i]?.classList.add('is-hovered')\n      showCard(i)\n    } else {\n      hideCard()\n    }\n  }\n  const nearestTick = (clientY: number): number => {\n    let best = -1\n    let bestDist = Infinity\n    for (let i = 0; i < ticks.length; i++) {\n      const r = ticks[i].getBoundingClientRect()\n      const d = Math.abs(clientY - (r.top + r.height / 2))\n      if (d < bestDist) { bestDist = d; best = i }\n    }\n    return best\n  }\n  const onPointerMove = (e: PointerEvent) => {\n    lastPointer = { x: e.clientX, y: e.clientY }\n    if (ticks.length) setHovered(nearestTick(e.clientY))\n  }\n  const onPointerLeave = () => { lastPointer = null; setHovered(-1) }\n  // A scroll can move the rail out from under a stationary cursor (the page scrolling while\n  // the pointer rests on the rail). No pointer event fires for that, so hover state would\n  // strand until the next real mouse move — re-check the last known pointer position against\n  // the rail's current rect whenever anything scrolls.\n  const revalidateHover = () => {\n    if (!lastPointer) return\n    const r = nav.getBoundingClientRect()\n    const inside = lastPointer.x >= r.left && lastPointer.x <= r.right\n      && lastPointer.y >= r.top && lastPointer.y <= r.bottom\n    if (!inside) { lastPointer = null; setHovered(-1) }\n    else if (ticks.length) setHovered(nearestTick(lastPointer.y))\n  }\n  // Keyboard: focusing a tick engages it; clear only when focus leaves the rail entirely\n  // (so Tabbing between ticks doesn't flicker the card).\n  const onFocusOut = (e: FocusEvent) => {\n    if (!nav.contains(e.relatedTarget as Node | null)) setHovered(-1)\n  }\n  // Navigate to a stop. Scrolls only the configured container — scrollIntoView would also\n  // scroll every other scrollable ancestor (the page itself lurches, and the rail slides out\n  // from under the cursor). Respects the target's scroll-margin-top like scrollIntoView does.\n  const go = (i: number) => {\n    const it = items[i]\n    if (!it) return\n    const c = opts.scrollContainer\n    const margin = parseFloat(getComputedStyle(it.target).scrollMarginTop) || 0\n    const top = it.target.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop - margin\n    c.scrollTo({ top, behavior: 'smooth' })\n  }\n  // A click anywhere in the rail navigates to the nearest tick — clicking the gaps works\n  // like clicking the ticks. Clicks that land on a tick are left to its own handler (which\n  // also covers keyboard activation, where there's no meaningful cursor position).\n  const onClick = (e: MouseEvent) => {\n    if ((e.target as Element | null)?.closest('.scroll-rail-tick')) return\n    const i = nearestTick(e.clientY)\n    if (i >= 0) go(i)\n  }\n  nav.addEventListener('pointermove', onPointerMove)\n  nav.addEventListener('pointerleave', onPointerLeave)\n  nav.addEventListener('focusout', onFocusOut)\n  nav.addEventListener('click', onClick)\n\n  const paintActive = (id: string | null) => {\n    ticks.forEach((t, i) => t.classList.toggle('is-active', items[i]?.id === id))\n  }\n\n  const observer = observeActive({\n    scrollContainer: opts.scrollContainer,\n    items: opts.items,\n    activationOffset: opts.activationOffset,\n    onActiveChange: (id) => { paintActive(id); opts.onActiveChange?.(id) },\n  })\n\n  // Capture-phase so it fires for any scroller (the page, the container, anything between).\n  document.addEventListener('scroll', revalidateHover, { capture: true, passive: true })\n\n  const buildTicks = () => {\n    track.replaceChildren()\n    ticks = items.map((it, i) => {\n      const tick = document.createElement('button')\n      tick.type = 'button'\n      tick.className = 'scroll-rail-tick'\n      tick.dataset.level = String(Math.min(Math.max(it.level ?? 1, 1), 4))\n      tick.setAttribute('aria-label', it.label)\n      if (it.color) tick.style.setProperty('--tick', it.color)\n      tick.addEventListener('click', () => go(i))\n      // Mouse hover is handled at the rail level (pointermove → nearest tick); focus is the\n      // keyboard path.\n      tick.addEventListener('focus', () => setHovered(i))\n      track.appendChild(tick)\n      return tick\n    })\n  }\n\n  const setItems = (next: ScrollRailItem[]) => {\n    items = next\n    hoveredIndex = -1\n    hideCard()\n    buildTicks()\n    observer.setItems(next)\n    paintActive(observer.getActiveId())\n  }\n  setItems(opts.items)\n\n  return {\n    element: nav,\n    getActiveId: () => observer.getActiveId(),\n    setItems,\n    refresh: () => { observer.refresh(); paintActive(observer.getActiveId()) },\n    destroy() {\n      observer.destroy()\n      document.removeEventListener('scroll', revalidateHover, { capture: true })\n      nav.removeEventListener('pointermove', onPointerMove)\n      nav.removeEventListener('pointerleave', onPointerLeave)\n      nav.removeEventListener('focusout', onFocusOut)\n      nav.removeEventListener('click', onClick)\n    },\n  }\n}\n\n// ── Heading adapter ──────────────────────────────────────────────────────────────────\n// The common case: turn the headings inside a container into rail stops. `headingItems`\n// queries + assigns slug ids; `itemsFromHeadings` maps an array you already have.\n\nexport interface HeadingItemsOptions {\n  /** Which headings to include. Default 'h1, h2, h3'. */\n  selector?: string\n  /** A selector to skip (e.g. a doc-title heading). */\n  exclude?: string\n  /** Max characters of preview text pulled from each section. Default `DEFAULT_PREVIEW_CHARS`. */\n  previewChars?: number\n  /** Assign a slug id to headings missing one (needed for scroll targets + #links). Default true. */\n  assignIds?: boolean\n}\n\n/** Rail stops from the headings currently inside `container`. */\nexport function headingItems(container: HTMLElement, opts: HeadingItemsOptions = {}): ScrollRailItem[] {\n  const selector = opts.selector ?? 'h1, h2, h3'\n  const all = [...container.querySelectorAll<HTMLElement>(selector)]\n  const heads = opts.exclude ? all.filter((h) => !h.matches(opts.exclude!)) : all\n  return itemsFromHeadings(heads, opts)\n}\n\n/** Rail stops from an array of heading elements you already hold. */\nexport function itemsFromHeadings(headings: HTMLElement[], opts: HeadingItemsOptions = {}): ScrollRailItem[] {\n  const previewChars = opts.previewChars ?? DEFAULT_PREVIEW_CHARS\n  const assignIds = opts.assignIds ?? true\n  const used = new Set<string>()\n  headings.forEach((h) => { if (h.id) used.add(h.id) })\n  return headings.map((h) => {\n    if (assignIds && !h.id) {\n      const base = slugify(h.textContent ?? '')\n      let id = base\n      let n = 2\n      while (used.has(id)) { id = `${base}-${n}`; n += 1 }\n      used.add(id)\n      h.id = id\n    }\n    const level = Number(h.tagName.slice(1)) || 1\n    return { id: h.id, target: h, label: h.textContent ?? '', level, preview: sectionPreview(h, previewChars) }\n  })\n}\n\nfunction slugify(text: string): string {\n  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'section'\n}\n\n/** A short plain-text snippet of the prose following a heading, up to the next heading. */\nfunction sectionPreview(heading: HTMLElement, max: number): string {\n  const parts: string[] = []\n  let node = heading.nextElementSibling\n  while (node && !/^H[1-6]$/.test(node.tagName)) {\n    const t = node.textContent?.trim()\n    if (t) parts.push(t)\n    if (parts.join(' ').length >= max + 10) break\n    node = node.nextElementSibling\n  }\n  const text = parts.join(' ').replace(/\\s+/g, ' ').trim()\n  return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text\n}\n\n// ── Styles ───────────────────────────────────────────────────────────────────────────\n\nlet stylesInjected = false\n/** Inject the rail stylesheet once. Called automatically unless `injectStyles: false`. */\nexport function injectRailStyles(): void {\n  if (stylesInjected || typeof document === 'undefined') return\n  if (document.getElementById('scroll-rail-styles')) { stylesInjected = true; return }\n  const style = document.createElement('style')\n  style.id = 'scroll-rail-styles'\n  style.textContent = railStyles()\n  document.head.appendChild(style)\n  stylesInjected = true\n}\n\n/** The component's CSS as a string (for callers who inject styles themselves / SSR). */\nexport function railStyles(): string {\n  return `\n.scroll-rail {\n  position: absolute; top: 0; bottom: 0; z-index: 5;\n  display: flex; align-items: center;\n  color: var(--rail-tick, currentColor);\n  opacity: 0.5; transition: opacity 0.25s ease;\n  /* The whole strip is interactive (pointer picks the nearest tick), so the hand cursor\n     and navigation apply across it — not just on the 2px ticks. */\n  cursor: pointer;\n}\n/* Brightening is driven by the .is-hover class (set from pointer events + revalidated on\n   scroll) rather than :hover — Safari leaves :hover stuck when the page scrolls the rail\n   out from under a stationary cursor. */\n.scroll-rail.is-hover { opacity: 1; }\n.scroll-rail--right { right: 0; }\n.scroll-rail--left { left: 0; }\n.scroll-rail-track {\n  display: flex; flex-direction: column; justify-content: center;\n  gap: 4px; max-height: 100%; padding: 12px 10px;\n}\n.scroll-rail--right .scroll-rail-track { align-items: flex-end; }\n.scroll-rail--left .scroll-rail-track { align-items: flex-start; }\n.scroll-rail-tick {\n  appearance: none; -webkit-appearance: none; border: 0; margin: 0; padding: 0;\n  cursor: pointer; height: 2px; width: 12px;\n  /* Buttons don't inherit color by default, so inherit it explicitly — that's what makes\n     currentColor (and thus --rail-tick) actually reach the ticks. */\n  color: inherit;\n  /* --tick is an optional per-node color (set inline); falls back to the shared tick color. */\n  background: var(--tick, currentColor); opacity: 0.55;\n  transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.22s ease, background-color 0.22s ease;\n}\n.scroll-rail-tick[data-level=\"2\"] { width: 9px; }\n.scroll-rail-tick[data-level=\"3\"] { width: 6px; }\n.scroll-rail-tick[data-level=\"4\"] { width: 4px; }\n/* The whole column swells while the rail is hovered — every tick scales up ~1.1×… */\n.scroll-rail.is-hover .scroll-rail-tick { width: 13px; }\n.scroll-rail.is-hover .scroll-rail-tick[data-level=\"2\"] { width: 10px; }\n.scroll-rail.is-hover .scroll-rail-tick[data-level=\"3\"] { width: 7px; }\n.scroll-rail.is-hover .scroll-rail-tick[data-level=\"4\"] { width: 4px; }\n/* .is-hovered is set (via pointermove) on the tick nearest the cursor anywhere in the rail,\n   so hover doesn't drop out in the gaps between ticks. Tick :hover is deliberately unused —\n   .is-hovered covers it and, unlike :hover, can't strand on scroll-under-cursor. */\n.scroll-rail-tick:focus-visible, .scroll-rail-tick.is-hovered { opacity: 1; outline: none; }\n/* A colored node keeps its own color when active; uncolored ones use the accent. */\n.scroll-rail-tick.is-active { width: 16px; opacity: 1; background: var(--tick, var(--rail-accent, ${DEFAULT_ACCENT_COLOR})); }\n/* …and the engaged/active tick rises above the scaled-up baseline. Engaging a tick always\n   puts .is-hover on the rail too, so these rules cover the keyboard-focus path as well.\n   (Same specificity as the level rules above — order matters.) */\n.scroll-rail.is-hover .scroll-rail-tick:focus-visible,\n.scroll-rail.is-hover .scroll-rail-tick.is-hovered,\n.scroll-rail.is-hover .scroll-rail-tick.is-active { width: 17px; }\n.scroll-rail-card {\n  position: absolute; z-index: 10; box-sizing: border-box;\n  width: var(--rail-card-width, ${DEFAULT_CARD_WIDTH}); max-width: var(--rail-card-width, ${DEFAULT_CARD_WIDTH});\n  background: var(--rail-card-bg, Canvas); color: var(--rail-card-fg, CanvasText);\n  border: 1px solid var(--rail-card-border, color-mix(in srgb, currentColor 20%, transparent));\n  border-radius: 10px; padding: 9px 12px;\n  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22);\n  opacity: 0; pointer-events: none;\n  /* top transitions so the card glides between ticks as the nearest one changes. */\n  transition: opacity 0.16s ease, transform 0.16s ease, top 0.22s cubic-bezier(0.22, 1, 0.36, 1);\n}\n.scroll-rail--right .scroll-rail-card { right: calc(100% + 8px); transform: translateY(-50%) translateX(4px); }\n.scroll-rail--left .scroll-rail-card { left: calc(100% + 8px); transform: translateY(-50%) translateX(-4px); }\n.scroll-rail-card.is-visible { opacity: 1; transform: translateY(-50%) translateX(0); }\n.scroll-rail-card-title { font-weight: 600; font-size: 13px; line-height: 1.4; }\n.scroll-rail-card-preview {\n  margin-top: 3px; font-size: 12px; line-height: 1.5; opacity: 0.7;\n  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;\n}\n`\n}\n",
      "type": "registry:lib",
      "target": "components/scroll-rail/scroll-rail.ts"
    },
    {
      "path": "registry/scroll-rail/scroll-rail-react.tsx",
      "content": "'use client'\n\n// scroll-rail-react — a thin React wrapper over the framework-agnostic scroll-rail core.\n//\n//   <ScrollRail scrollRef={ref} items={items} />   renders the rail\n//   useActiveStop({ scrollRef, items })            headless: just the active id\n//\n// Both take `items` whose `target` is either an element or an element id (string) resolved\n// inside the scroll container — so you can drive it from server-rendered ids without refs.\n\nimport { useEffect, useRef, useState, type RefObject } from 'react'\nimport {\n  createScrollRail,\n  observeActive,\n  type ScrollRail as VanillaScrollRail,\n  type ScrollRailItem,\n} from './scroll-rail'\n\n/** Like ScrollRailItem, but `target` may be an element id resolved inside the container. */\ninterface ReactScrollRailItem extends Omit<ScrollRailItem, 'target'> {\n  target: HTMLElement | string\n}\n\nfunction resolveItems(items: ReactScrollRailItem[], scope: HTMLElement): ScrollRailItem[] {\n  const out: ScrollRailItem[] = []\n  for (const it of items) {\n    const target =\n      typeof it.target === 'string'\n        ? scope.querySelector<HTMLElement>(`#${cssEscape(it.target)}`)\n        : it.target\n    if (target) out.push({ ...it, target })\n  }\n  return out\n}\n\nfunction cssEscape(id: string): string {\n  const c = (globalThis as { CSS?: { escape?(s: string): string } }).CSS\n  return c?.escape ? c.escape(id) : id.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&')\n}\n\ninterface ScrollRailProps {\n  /** Ref to the scrolling element the stops live inside. */\n  scrollRef: RefObject<HTMLElement | null>\n  items: ReactScrollRailItem[]\n  position?: 'left' | 'right'\n  activationOffset?: number\n  onActiveChange?: (id: string | null) => void\n  className?: string\n}\n\n/**\n * Renders the scroll rail. Mount it inside the same `position: relative` box that holds\n * your scroll container — the rail pins itself to that box's edge.\n *\n * The returned wrapper is `display: contents`, so it adds no layout box of its own; the\n * rail (absolutely positioned) resolves against your relative ancestor.\n */\nfunction ScrollRail({\n  scrollRef, items, position, activationOffset, onActiveChange, className,\n}: ScrollRailProps) {\n  const hostRef = useRef<HTMLDivElement>(null)\n  const railRef = useRef<VanillaScrollRail | null>(null)\n  // Keep the latest onActiveChange without re-creating the rail each render.\n  const activeCb = useRef(onActiveChange)\n  activeCb.current = onActiveChange\n\n  useEffect(() => {\n    const scroll = scrollRef.current\n    const host = hostRef.current\n    if (!scroll || !host) return\n    const rail = createScrollRail({\n      scrollContainer: scroll,\n      items: resolveItems(items, scroll),\n      position,\n      activationOffset,\n      className,\n      onActiveChange: (id) => activeCb.current?.(id),\n    })\n    host.appendChild(rail.element)\n    railRef.current = rail\n    return () => {\n      rail.destroy()\n      rail.element.remove()\n      railRef.current = null\n    }\n    // Re-create only on structural option changes; item updates are handled below.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [scrollRef, position, activationOffset, className])\n\n  // Re-sync stops when the items change, without tearing down the rail.\n  useEffect(() => {\n    const scroll = scrollRef.current\n    if (railRef.current && scroll) railRef.current.setItems(resolveItems(items, scroll))\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [items])\n\n  return <div ref={hostRef} style={{ display: 'contents' }} />\n}\n\ninterface UseActiveStopOptions {\n  scrollRef: RefObject<HTMLElement | null>\n  items: ReactScrollRailItem[]\n  activationOffset?: number\n}\n\n/**\n * Headless: track which stop is active as the container scrolls, returning its id (or null).\n * The shadcn `useMessageScrollerVisibility` parallel — bring your own UI.\n */\nfunction useActiveStop({ scrollRef, items, activationOffset }: UseActiveStopOptions): string | null {\n  const [activeId, setActiveId] = useState<string | null>(null)\n  useEffect(() => {\n    const scroll = scrollRef.current\n    if (!scroll) return\n    const obs = observeActive({\n      scrollContainer: scroll,\n      items: resolveItems(items, scroll),\n      activationOffset,\n      onActiveChange: setActiveId,\n    })\n    return () => obs.destroy()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [scrollRef, items, activationOffset])\n  return activeId\n}\n\nexport {\n  ScrollRail,\n  useActiveStop,\n  type ScrollRailItem,\n  type ReactScrollRailItem,\n  type ScrollRailProps,\n  type UseActiveStopOptions,\n}\n",
      "type": "registry:component",
      "target": "components/scroll-rail/scroll-rail-react.tsx"
    }
  ],
  "type": "registry:component"
}