{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-spine","type":"registry:component","title":"Scroll spine","description":"Maps the article as a spine of bands, each as long as its section, filling in as you read.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/scroll-spine.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* The teachable part: every section gets a band on the spine as tall as the\n   section is long, so the spine is a scale drawing of the article. The\n   marker covering your section moves like a caterpillar: its leading edge\n   springs ahead and the trailing edge catches up, so it stretches while it\n   travels and settles to the size of the section it lands on. */\n\nexport type SpineItem = { id: string; label: string };\n\ntype Band = { top: number; height: number };\n\n// Room between bands so neighbouring sections read as separate pieces.\nconst GAP = 8;\n// A one-paragraph section still gets a band big enough to hover and click,\n// and, with labels, tall enough for its heading's line.\nconst MIN_BAND = 14;\nconst MIN_BAND_LABELLED = 24;\n// Below this width there is no room for headings beside the bands.\nconst INLINE_MIN = 120;\n// The current-section block reaches a little past its band.\nconst MARK_PAD = 4;\n// Where on screen the \"reading line\" sits: a third of the way down is where\n// eyes rest while reading, so a section counts as current once its heading\n// passes that line, not only when it hits the very top.\nconst READ_LINE = 0.3;\n// Scroll positions land a few pixels short of the end on some trackpads.\nconst END_SLACK = 4;\n// Space left above a heading after jumping to it, so it isn't flush.\nconst JUMP_OFFSET = 16;\n// Leading edge: quick, so the marker answers the scroll at once.\nconst LEAD = { type: \"spring\", visualDuration: 0.24, bounce: 0 } as const;\n// Trailing edge: slower on purpose, that lag is the stretch. 0.42s is the\n// shortest lag where the stretch still reads on a one-band move.\nconst TRAIL = { type: \"spring\", visualDuration: 0.42, bounce: 0 } as const;\n\ntype Target = { kind: \"element\"; el: HTMLElement } | { kind: \"window\" };\n\nfunction metrics(target: Target) {\n  if (target.kind === \"window\") {\n    return {\n      scrollTop: window.scrollY,\n      viewport: window.innerHeight,\n      height: document.documentElement.scrollHeight,\n      offsetOf: (el: HTMLElement) => el.getBoundingClientRect().top + window.scrollY,\n    };\n  }\n  const box = target.el;\n  const top = box.getBoundingClientRect().top;\n  return {\n    scrollTop: box.scrollTop,\n    viewport: box.clientHeight,\n    height: box.scrollHeight,\n    offsetOf: (el: HTMLElement) => el.getBoundingClientRect().top - top + box.scrollTop,\n  };\n}\n\nexport function ScrollSpine({\n  items,\n  scrollRef,\n  height = 320,\n  label = \"On this page\",\n  className,\n}: {\n  items: SpineItem[];\n  /* The element that scrolls. Leave it out to follow the window, which is\n     what a real article page wants. Headings are found by id either way. */\n  scrollRef?: React.RefObject<HTMLElement | null>;\n  height?: number;\n  label?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const [bands, setBands] = useState<Band[]>([]);\n  const [current, setCurrent] = useState(0);\n  const [preview, setPreview] = useState<number | null>(null);\n  // Wide enough for words: every band carries its heading. Narrow: bands\n  // only, and the heading shows on hover or focus.\n  const [inline, setInline] = useState(true);\n  const markTop = useMotionValue(0);\n  const markBottom = useMotionValue(0);\n  const markHeight = useTransform([markTop, markBottom], ([t, b]: number[]) =>\n    Math.max(0, b - t),\n  );\n  const nav = useRef<HTMLElement>(null);\n  const notch = useRef<HTMLDivElement>(null);\n  const fills = useRef<(HTMLSpanElement | null)[]>([]);\n  // Scroll events read these, so they never wait on a render.\n  const offsets = useRef<number[]>([]);\n  const docEnd = useRef(0);\n  const bandsRef = useRef<Band[]>([]);\n  const currentRef = useRef(0);\n  const placed = useRef(false);\n\n  const target = useCallback((): Target | null => {\n    if (!scrollRef) return { kind: \"window\" };\n    return scrollRef.current ? { kind: \"element\", el: scrollRef.current } : null;\n  }, [scrollRef]);\n\n  useEffect(() => {\n    const el = nav.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) =>\n      setInline(entry.contentRect.width >= INLINE_MIN),\n    );\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Measure section lengths and lay the bands out in proportion.\n  useEffect(() => {\n    const t = target();\n    if (!t) return;\n    const minBand = inline ? MIN_BAND_LABELLED : MIN_BAND;\n    const measure = () => {\n      const m = metrics(t);\n      const tops = items.map((item) => {\n        const el = document.getElementById(item.id);\n        return el ? m.offsetOf(el) : 0;\n      });\n      offsets.current = tops;\n      docEnd.current = m.height;\n      const lens = tops.map((top, i) => Math.max(1, (tops[i + 1] ?? m.height) - top));\n      const total = lens.reduce((a, b) => a + b, 0);\n      const free = height - GAP * (items.length - 1) - minBand * items.length;\n      let y = 0;\n      const next = lens.map((len) => {\n        const band = { top: y, height: minBand + (free * len) / total };\n        y += band.height + GAP;\n        return band;\n      });\n      bandsRef.current = next;\n      setBands(next);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(t.kind === \"window\" ? document.body : (t.el.firstElementChild ?? t.el));\n    return () => ro.disconnect();\n  }, [items, height, target, inline]);\n\n  // Follow the scroll: the reading dot and the read part of each band are\n  // written straight to the DOM every frame; React only hears about it when\n  // the current section changes.\n  useEffect(() => {\n    const t = target();\n    if (!t || bands.length === 0) return;\n    const scroller: HTMLElement | Window = t.kind === \"window\" ? window : t.el;\n    let frame = 0;\n    const update = () => {\n      frame = 0;\n      const m = metrics(t);\n      const tops = offsets.current;\n      const atEnd = m.scrollTop >= m.height - m.viewport - END_SLACK;\n      const line = m.scrollTop + m.viewport * READ_LINE;\n      let i = 0;\n      while (i < tops.length - 1 && tops[i + 1] <= line) i++;\n      if (atEnd) i = tops.length - 1;\n      const start = tops[i];\n      const end = tops[i + 1] ?? docEnd.current;\n      const within = atEnd ? 1 : Math.min(1, Math.max(0, (line - start) / (end - start)));\n      const band = bandsRef.current[i];\n      if (band && notch.current) {\n        notch.current.style.transform = `translateY(${band.top + within * band.height}px)`;\n      }\n      fills.current.forEach((f, k) => {\n        if (f) f.style.transform = `scaleY(${k < i ? 1 : k === i ? within : 0})`;\n      });\n      if (i !== currentRef.current) {\n        currentRef.current = i;\n        setCurrent(i);\n      }\n    };\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n    update();\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => {\n      scroller.removeEventListener(\"scroll\", onScroll);\n      cancelAnimationFrame(frame);\n    };\n  }, [bands, target]);\n\n  // Move the marker. The edge in the direction of travel leads.\n  useEffect(() => {\n    const band = bands[current];\n    if (!band) return;\n    const top = band.top - MARK_PAD;\n    const bottom = band.top + band.height + MARK_PAD;\n    if (!placed.current || reduceMotion) {\n      placed.current = true;\n      markTop.jump(top);\n      markBottom.jump(bottom);\n      return;\n    }\n    const down = top >= markTop.get();\n    // Not stopped between moves: a new animate() takes over mid-flight and\n    // keeps the edge's velocity, so fast scrolling bends instead of restarts.\n    animate(markTop, top, down ? TRAIL : LEAD);\n    animate(markBottom, bottom, down ? LEAD : TRAIL);\n  }, [bands, current, reduceMotion, markTop, markBottom]);\n\n  useEffect(\n    () => () => {\n      markTop.stop();\n      markBottom.stop();\n    },\n    [markTop, markBottom],\n  );\n\n  const jump = (i: number) => {\n    const t = target();\n    if (!t) return;\n    const top = Math.max(0, offsets.current[i] - JUMP_OFFSET);\n    const behavior = reduceMotion ? \"auto\" : \"smooth\";\n    if (t.kind === \"window\") window.scrollTo({ top, behavior });\n    else t.el.scrollTo({ top, behavior });\n  };\n\n  return (\n    <nav\n      ref={nav}\n      aria-label={label}\n      className={cn(\"relative w-[180px] shrink-0 select-none\", className)}\n      style={{ height }}\n    >\n      {/* The current section, as a soft block that stretches from band to\n          band like a caterpillar. */}\n      <motion.div\n        aria-hidden\n        style={{ top: markTop, height: markHeight }}\n        className={cn(\n          \"absolute rounded-lg bg-foreground/[0.06]\",\n          inline ? \"-left-2 right-0\" : \"left-1/2 w-5 -translate-x-1/2\",\n        )}\n      />\n      <ol className=\"absolute inset-0\">\n        {bands.map((band, i) => {\n          const active = i === current;\n          const shown = preview === i && !inline;\n          return (\n            <li\n              key={items[i].id}\n              className=\"absolute right-0 left-0\"\n              style={{ top: band.top, height: band.height }}\n            >\n              {/* The band: as tall as the section is long, filling in as\n                  you read through it. */}\n              <span\n                aria-hidden\n                className={cn(\n                  \"absolute inset-y-0 w-[3px] overflow-hidden rounded-full bg-foreground/[0.12]\",\n                  inline ? \"left-0\" : \"left-1/2 -translate-x-1/2\",\n                )}\n              >\n                <span\n                  ref={(el) => {\n                    fills.current[i] = el;\n                  }}\n                  // Written by the scroll handler; starts empty. Not the\n                  // Tailwind scale utility, which would multiply with it.\n                  style={{ transform: \"scaleY(0)\" }}\n                  className=\"absolute inset-0 origin-top rounded-full bg-foreground\"\n                />\n              </span>\n              <button\n                type=\"button\"\n                aria-current={active ? \"location\" : undefined}\n                onClick={() => jump(i)}\n                onPointerEnter={(e) => {\n                  if (e.pointerType !== \"touch\") setPreview(i);\n                }}\n                onPointerLeave={() => setPreview((p) => (p === i ? null : p))}\n                onFocus={(e) => {\n                  if (e.currentTarget.matches(\":focus-visible\")) setPreview(i);\n                }}\n                onBlur={() => setPreview((p) => (p === i ? null : p))}\n                className={cn(\n                  \"group/band absolute touch-manipulation rounded-md text-left outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\",\n                  inline ? \"inset-y-0 -left-2 right-0 pl-5\" : \"inset-0\",\n                )}\n              >\n                {inline ? (\n                  <span\n                    className={cn(\n                      \"block truncate text-[13px] leading-4 transition-[color] duration-150 ease-out\",\n                      active\n                        ? \"font-medium text-foreground\"\n                        : \"text-muted-foreground group-hover/band:text-foreground\",\n                    )}\n                  >\n                    {items[i].label}\n                  </span>\n                ) : (\n                  <span className=\"sr-only\">{items[i].label}</span>\n                )}\n              </button>\n              {!inline && (\n                // Preview label, hung off the left of the band.\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"pointer-events-none absolute top-0 right-full z-10 mr-1 rounded-full bg-foreground px-3 py-1.5 text-[13px] font-medium whitespace-nowrap text-background\",\n                    \"transition-[opacity,translate,filter] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-x-0\",\n                    shown\n                      ? \"translate-x-0 opacity-100 blur-none duration-200\"\n                      : \"translate-x-1.5 opacity-0 blur-[2px] duration-100\",\n                  )}\n                >\n                  {items[i].label}\n                </span>\n              )}\n            </li>\n          );\n        })}\n      </ol>\n      {/* Exactly where the reading line is, riding the bands. */}\n      <div\n        ref={notch}\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute top-0 -mt-[4.5px] size-[9px] rounded-full bg-foreground ring-[3px] ring-muted\",\n          inline ? \"-left-[3px]\" : \"left-1/2 -ml-[4.5px]\",\n          bands.length === 0 && \"opacity-0\",\n        )}\n      />\n    </nav>\n  );\n}\n\n/* An article page mock with its own scroll container. */\n\ntype Section = { id: string; heading: string; body: string[] };\n\nconst SECTIONS: Section[] = [\n  {\n    id: \"spine-why\",\n    heading: \"Why cold starts matter\",\n    body: [\n      \"Our checkout function woke up in 2.1 seconds on a cold start. On a warm path it answered in 40ms, so every idle stretch of five minutes was quietly costing us the first customer back.\",\n      \"The fix was not one change but four, and the order we made them in mattered more than any single one.\",\n    ],\n  },\n  {\n    id: \"spine-measure\",\n    heading: \"Measuring the right thing\",\n    body: [\n      \"We started with averages and learned nothing. The p50 cold start looked fine because most requests were warm. What we needed was the p99 of requests that followed ten minutes of silence.\",\n      \"A tiny scheduled probe did the job: it waited, fired one request, recorded the timing, and went back to sleep. Two weeks of that gave us a curve worth arguing about.\",\n      \"The curve had two humps. One was module loading, the other was the database handshake, and they were almost exactly the same size.\",\n      \"That second hump surprised everyone, because the connection pool was supposed to hide it.\",\n    ],\n  },\n  {\n    id: \"spine-bundle\",\n    heading: \"Shrinking the bundle\",\n    body: [\n      \"The function shipped 14 MB of JavaScript, most of it an SDK we called twice. Replacing it with two fetch calls took an afternoon and removed 11 MB.\",\n      \"Tree shaking did the rest once we stopped importing from barrel files.\",\n    ],\n  },\n  {\n    id: \"spine-pool\",\n    heading: \"The connection pool lie\",\n    body: [\n      \"A pool only helps if something is in it. After an idle period the platform froze the process, the sockets went stale, and the first query paid for a full TLS handshake plus a retry.\",\n      \"We moved to an HTTP based driver that needs no socket at all. The handshake hump disappeared from the curve overnight.\",\n      \"It cost us transactions across multiple statements, which we replaced with a single stored procedure for the one place that needed them.\",\n    ],\n  },\n  {\n    id: \"spine-result\",\n    heading: \"Where we landed\",\n    body: [\n      \"Cold starts now sit at 380ms at the p99. Nobody notices them any more, which is the whole point.\",\n    ],\n  },\n  {\n    id: \"spine-next\",\n    heading: \"What we would do next\",\n    body: [\n      \"Keep the probe. It has already caught one regression, a logging library that grew by 3 MB in a minor release.\",\n      \"And measure before you tune. Every one of our first guesses was wrong.\",\n    ],\n  },\n];\n\nconst ITEMS: SpineItem[] = SECTIONS.map((s) => ({ id: s.id, label: s.heading }));\n\nexport default function ScrollSpineDemo() {\n  const scroller = useRef<HTMLDivElement>(null);\n  return (\n    <div className=\"@container flex h-[440px] w-[620px] max-w-full overflow-hidden rounded-2xl bg-muted shadow-raised\">\n      <div\n        ref={scroller}\n        tabIndex={0}\n        aria-label=\"Article\"\n        // The bottom fade says there is more below.\n        className=\"min-w-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:none] outline-hidden [&::-webkit-scrollbar]:hidden [mask-image:linear-gradient(to_bottom,black_calc(100%-48px),transparent)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-foreground\"\n      >\n        <article className=\"px-6 pt-6 pb-40 text-[15px] leading-relaxed text-pretty text-muted-foreground\">\n          <p className=\"text-[13px]\">Engineering · 6 min read</p>\n          <h2 className=\"mt-1 text-xl font-semibold text-balance text-foreground\">\n            How we cut cold starts from 2.1s to 380ms\n          </h2>\n          {SECTIONS.map((s) => (\n            <section key={s.id}>\n              <h3 id={s.id} className=\"mt-7 scroll-mt-4 text-base font-medium text-foreground\">\n                {s.heading}\n              </h3>\n              {s.body.map((p) => (\n                <p key={p.slice(0, 24)} className=\"mt-3\">\n                  {p}\n                </p>\n              ))}\n            </section>\n          ))}\n        </article>\n      </div>\n      {/* Headings beside the bands when there is room; bands alone on a\n          phone, where the heading shows on hover or focus. */}\n      <div className=\"flex w-12 shrink-0 flex-col items-center border-l border-border pt-6 @min-[520px]:w-[212px] @min-[520px]:items-stretch @min-[520px]:pr-5 @min-[520px]:pl-6\">\n        <p className=\"mb-4 hidden text-xs font-medium tracking-[0.06em] text-muted-foreground uppercase @min-[520px]:block\">\n          On this page\n        </p>\n        <ScrollSpine\n          items={ITEMS}\n          scrollRef={scroller}\n          height={340}\n          className=\"w-5 @min-[520px]:w-full\"\n        />\n      </div>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"marker":"#d93d31","shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"marker":"#ff6b5f","shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"color-marker":"var(--marker)","shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/scroll-spine"}