## Scroll Spy — Scroll The section you are actually in. Docs: https://www.interior.dev/docs/scroll-spy Reference: https://www.interior.dev/reference/scroll-spy License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/scroll-spy.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useRef } from "react"; import { ScrollSpy } from "@/components/interior/scroll-spy"; const sections = [ { id: "install", label: "Install" }, { id: "usage", label: "Usage" }, { id: "props", label: "Props" }, { id: "guarantees", label: "Guarantees" }, ]; export function DocsArticle() { const article = useRef(null); return (
{sections.map((section) => (

{section.label}

{/* body */}
))}
); } ``` ### Props - `sections` (`ScrollSpySection[]`) Ordered list of { id, label }. Each id is resolved from the document, so the content markup needs no wiring beyond the id it already has. - `offset` (`number`) — default: `96` Distance in px from the top of the scroll root to the reading line. Set it to the height of whatever sticky header covers the content. - `root` (`React.RefObject`) — default: `undefined` A scroll container to watch. Omitted, the window is the scroller. - `onChange` (`(id: string) => void`) — default: `undefined` Fires once per section change, never per frame. Held in a ref, so an inline arrow does not re-bind the listeners. - `label` (`string`) — default: `"On this page"` Accessible name for the nav landmark. - `className` (`string`) — default: `""` Appended last on the nav element. ### Behavior notes - Clicking a link does not make the rail flip through every heading the smooth scroll passes over: the destination is locked in until the scroll arrives, and the lock is abandoned the instant a wheel or touch takes the scroll back, so the rail never argues with the person driving. - The reading line is not pinned: it starts under the top edge and slides to the bottom as the scroll runs out. A pinned line skips every short section near the end — their headings can never climb above it before the scroll is over — which is why the tail links in a hand-rolled spy are dead. On the sliding line each one still gets its turn, and the true end still clamps to the last section. - Positions are read from live rects in one rAF per scroll event rather than from offsets cached on mount, and a ResizeObserver covers the rest, so an accordion opening or an image loading late does not leave the rail pointing at the wrong heading. - The spy is a strip, not a sidebar: every section is a chip in a recessed track and the current one is carried by a single ink thumb sliding chip to chip — the same object the tabs and the segmented control already taught. The row scrolls sideways when the page has more sections than the strip has width, and the active chip keeps itself in view. - The active section is one discrete id, so React renders when the section changes and not once per scroll frame; nothing in the strip is driven by a float. - Every chip's label is drawn over an invisible medium twin in the same grid cell, so the weight arriving with the thumb cannot change the chip's width and reflow the row. - Screen readers get the section name once, after the scroll settles, instead of a running commentary; a click moves focus to the heading itself so the keyboard lands where the eye does, and under prefers-reduced-motion the scroll jumps rather than glides. ### Source (`components/interior/scroll-spy.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const SETTLE = 420; const RELEASE = 900; export type ScrollSpySection = { id: string; label: string; }; export type UseScrollSpyOptions = { sections: ScrollSpySection[]; offset?: number; root?: React.RefObject; onChange?: (id: string) => void; }; export function useScrollSpy({ sections, offset = 96, root, onChange, }: UseScrollSpyOptions) { const reduced = useReducedMotion(); const [activeId, setActiveId] = useState(() => sections[0]?.id ?? ""); const [announce, setAnnounce] = useState(""); const list = useRef(sections); list.current = sections; const emit = useRef(onChange); emit.current = onChange; const frame = useRef(0); const lock = useRef(null); const lockTimer = useRef | null>(null); const settleTimer = useRef | null>(null); const started = useRef(false); const key = sections.map((s) => s.id).join("|"); const measure = useCallback(() => { const items = list.current; if (items.length === 0) return ""; const container = root?.current ?? null; const viewport = container ? container.clientHeight : window.innerHeight; const top = container ? container.scrollTop : window.scrollY; const max = container ? container.scrollHeight - container.clientHeight : document.documentElement.scrollHeight - window.innerHeight; const ratio = max > 0 ? Math.min(1, Math.max(0, top / max)) : 1; const line = (container ? container.getBoundingClientRect().top : 0) + offset + ratio * Math.max(0, viewport - offset - 1); let current = ""; let last = ""; for (const item of items) { const node = document.getElementById(item.id); if (!node) continue; last = item.id; if (!current) current = item.id; if (node.getBoundingClientRect().top <= line + 1) current = item.id; } const atEnd = container ? container.scrollTop + container.clientHeight >= container.scrollHeight - 2 : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2; return atEnd && last ? last : current; }, [offset, root]); const release = useCallback(() => { lock.current = null; if (lockTimer.current) { clearTimeout(lockTimer.current); lockTimer.current = null; } }, []); const sync = useCallback(() => { if (frame.current) return; frame.current = requestAnimationFrame(() => { frame.current = 0; const next = measure(); if (!next) return; if (lock.current) { if (lock.current === next) release(); return; } setActiveId((prev) => (prev === next ? prev : next)); }); }, [measure, release]); useEffect(() => { const container = root?.current ?? null; const scroller: EventTarget = container ?? window; const abandon = () => { if (lock.current) release(); }; scroller.addEventListener("scroll", sync, { passive: true }); window.addEventListener("resize", sync); window.addEventListener("wheel", abandon, { passive: true }); window.addEventListener("touchstart", abandon, { passive: true }); const observer = new ResizeObserver(sync); observer.observe(container ?? document.documentElement); for (const id of key ? key.split("|") : []) { const node = document.getElementById(id); if (node) observer.observe(node); } sync(); return () => { scroller.removeEventListener("scroll", sync); window.removeEventListener("resize", sync); window.removeEventListener("wheel", abandon); window.removeEventListener("touchstart", abandon); observer.disconnect(); cancelAnimationFrame(frame.current); frame.current = 0; if (lockTimer.current) clearTimeout(lockTimer.current); }; }, [sync, release, key, root]); useEffect(() => { if (!activeId) return; emit.current?.(activeId); if (!started.current) { started.current = true; return; } settleTimer.current = setTimeout(() => { const item = list.current.find((s) => s.id === activeId); setAnnounce(item ? item.label : ""); }, SETTLE); return () => { if (settleTimer.current) clearTimeout(settleTimer.current); }; }, [activeId]); const scrollTo = useCallback( (id: string) => { const node = document.getElementById(id); if (!node) return; lock.current = id; setActiveId(id); const container = root?.current ?? null; const behavior: ScrollBehavior = reduced ? "auto" : "smooth"; const rect = node.getBoundingClientRect(); const viewport = container ? container.clientHeight : window.innerHeight; const max = container ? Math.max(0, container.scrollHeight - container.clientHeight) : Math.max( 0, document.documentElement.scrollHeight - window.innerHeight, ); const H = container ? rect.top - container.getBoundingClientRect().top + container.scrollTop : rect.top + window.scrollY; const usable = Math.max(0, viewport - offset - 1); const top = max > 0 ? Math.min(max, Math.max(0, (H - offset) / (1 + usable / max))) : 0; if (container) container.scrollTo({ top, behavior }); else window.scrollTo({ top, behavior }); if (!node.hasAttribute("tabindex")) node.setAttribute("tabindex", "-1"); node.focus({ preventScroll: true }); if (lockTimer.current) clearTimeout(lockTimer.current); lockTimer.current = setTimeout(() => { lock.current = null; lockTimer.current = null; sync(); }, RELEASE); }, [offset, reduced, root, sync], ); const getLinkProps = useCallback( (id: string) => ({ href: `#${id}`, "aria-current": id === activeId ? ("location" as const) : undefined, onClick: (e: React.MouseEvent) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); scrollTo(id); }, }), [activeId, scrollTo], ); const activeIndex = sections.findIndex((s) => s.id === activeId); return { activeId, activeIndex, scrollTo, getLinkProps, announce }; } export type ScrollSpyProps = { sections: ScrollSpySection[]; offset?: number; root?: React.RefObject; onChange?: (id: string) => void; label?: string; className?: string; }; export function ScrollSpy({ sections, offset = 96, root, onChange, label = "On this page", className = "", }: ScrollSpyProps) { const { activeId, getLinkProps, announce } = useScrollSpy({ sections, offset, root, onChange, }); const reduced = useReducedMotion(); const thumbId = useId(); const chips = useRef(new Map()); useEffect(() => { chips.current.get(activeId)?.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "nearest", inline: "nearest", }); }, [activeId, reduced]); return ( ); } ```