-
-
Notifications
You must be signed in to change notification settings - Fork 0
Scroll Spy
Opt-in DOM-layer utility that makes the URL hash track the topmost visible anchor as the user scrolls. Ships with every framework adapter as a
RouterProviderprop (orprovideRealRouteroption in Angular).
Real-router's core is DOM-agnostic — it produces { name, params } pairs, not DOM side-effects. IntersectionObserver is a DOM concern, so the utility lives in shared/dom-utils/scroll-spy.ts alongside createScrollRestoration / createRouteAnnouncer / createViewTransitions. The routing-layer write API the utility needs (forced same-route same-params transition with hashChange: true) is already part of the public router.navigate(...) contract via @real-router/browser-plugin / @real-router/navigation-plugin — a plugin would duplicate a channel that already exists.
This parallels the layered architecture: routing = identity, view adapters = DOM. The same rationale that places Scroll Restoration and View Transitions into shared/dom-utils/ applies here.
<RouterProvider
router={router}
scrollSpy={{ selector: "[id]:is(h2,h3)" }}
>
{/* ... */}
</RouterProvider><RouterProvider
router={router}
scrollSpy={{ selector: "[id]:is(h2,h3)" }}
>
{/* ... */}
</RouterProvider>Read once on mount — Solid onMount is non-reactive, consistent with scrollRestoration and viewTransitions.
<RouterProvider :router="router" :scroll-spy="{ selector: '[id]:is(h2,h3)' }">
<!-- ... -->
</RouterProvider>Reactive — toggling via ref creates/destroys the utility. Watched by primitive fields (selector, rootMargin), so inline objects with the same values do not thrash.
<RouterProvider {router} scrollSpy={{ selector: "[id]:is(h2,h3)" }}>
<!-- ... -->
</RouterProvider>Reactive via $effect — primitive fields wrapped in $derived; scrollContainer getter is pulled via untrack so identity changes don't retrigger.
import { provideRealRouter } from "@real-router/angular";
bootstrapApplication(AppComponent, {
providers: [
provideRealRouter(router, {
scrollSpy: { selector: "[id]:is(h2,h3)" },
}),
],
});Available on both provideRealRouter (SPA) and provideRealRouterFactory (SSR/SSG); on the SSR path the utility correctly NOOP's on the server pass (document is undefined). Options are a snapshot at bootstrap — not reactive to runtime changes.
Omit scrollSpy (or pass undefined / { selector: "" }) to disable. No global side-effects until you opt in.
interface ScrollSpyOptions {
selector: string;
rootMargin?: string;
scrollContainer?: () => HTMLElement | null;
}| Option | Default | Purpose |
|---|---|---|
selector |
required | CSS selector for anchor candidates. Empty string "" disables the spy (returns NOOP handle). Common values: "[id]", "[id]:is(h1,h2,h3)", "section[id]". |
rootMargin |
"-20% 0px -60% 0px" |
IntersectionObserver rootMargin. The default produces a narrow band near the viewport top — an anchor becomes "active" once it crosses into the top 20% of the viewport (or scroll container). Wider zones ("0px 0px -20% 0px") feel more continuous on long-form articles where sections are taller than the band. |
scrollContainer |
window |
Custom scroll root for virtual scrollers. Invoked lazily on every event — null returns fall back to window viewport transparently. |
Hardcoded internals (promote only with evidence — see RFC §11):
-
IntersectionObserver.threshold = 0— fires as soon as a pixel crosses into the active zone;rootMarginis the user-facing dial. - rAF + 150 ms trailing debounce — coalesces IO bursts so a fast scroll produces ≤ 1
router.navigateper debounce window. - MutationObserver re-observe debounced 250 ms — catches anchor additions / id renames in client-rendered docs.
- 500 ms cooldown fallback — when
scrollendevent isn't supported (older Safari), the safety timeout lifts the gate after a user-driven smoothscrollIntoView.
The spy emits a forced same-route same-params transition:
router.navigate(state.name, state.params, {
hash: newHash,
replace: true,
force: true,
hashChange: true,
});| Event | Result |
|---|---|
| User scrolls into a section | URL hash updates to the section's id; debounced ≤ 1 emit per 150 ms |
User clicks <Link hash="section-5">
|
Smooth scroll plays; spy's coolingDown gate skips intermediate IO events; URL lands on #section-5
|
F5 on /page#section-3
|
Browser native auto-scrolls to anchor; spy detects same hash → no emit (newHash === currentHash skip) |
/page#nonexistent (stale URL) |
First IO event after mount picks the topmost real anchor → URL self-heals on init (no user scroll needed) |
| Route without matching anchors | No emits; previous hash preserved |
| Same-hash transition | Skipped — newHash === state.context.url.hash short-circuit |
| Spy's own emit (re-entrant) |
selfEmitting guard prevents cooldown from being self-triggered |
Why replace: true? Each spy emit updates the existing history entry instead of pushing a new one. Without replace, scrolling through a 12-section article would create 12 history entries, making browser back useless. This also makes scroll-restoration treat spy emits as "skip restore" via the portable state.transition.replace signal — the spy and scroll-restoration don't fight over scroll position.
Why force: true, hashChange: true? For same-route same-params transitions, core normally rejects with SAME_STATES. force bypasses the rejection; hashChange tells the URL plugin to write state.context.url.hashChanged = true in its onTransitionSuccess claim. Same write API as <Link hash> (#532) via navigateWithHash.
Three composing gates inside createScrollSpy:
Read via getTransitionSource(router).getSnapshot().isTransitioning (per-router cached, eager subscription, auto-resets on success/error/cancel — robust to rejected/aborted transitions). Skips emits while a transition is in-flight. Prevents the spy from re-entering its own pipeline.
scrollIntoView({ behavior: "smooth" }) after a user-driven <Link hash> click animates after TRANSITION_SUCCESS. By that point isTransitioning is already false, and intermediate IO events during the smooth scroll would emit spurious router.navigate(...) calls — the URL bar would flicker #section-2 #section-3 #section-4 before landing on #section-5.
The cooldown gate closes the gap. On any user-driven hash transition the spy's router.subscribe callback sets coolingDown = true and listens for:
-
scrollendevent on the scroll container orwindow(Baseline 2026: Chrome 114+, Firefox 109+, Safari 17+). - 500 ms fallback timeout for browsers that ship
scrollIntoView({ behavior: "smooth" })but never firescrollend(older Safari).
Either signal clears coolingDown. IO events during the smooth animation are silently dropped.
hashChanged: true is symmetric on the bus — every subscriber sees the spy's own emit identically to a user-driven <Link hash> click. Without a guard, the spy's router.subscribe callback would re-enter the cooldown setup on every self-emit, rate-limiting the spy to ≤ 2 emits/sec.
The selfEmitting flag is set synchronously before router.navigate(...) and cleared in .finally():
selfEmitting = true;
router
.navigate(state.name, state.params, opts)
.catch(() => {})
.finally(() => { selfEmitting = false });The router.subscribe callback branches: hashChanged && !selfEmitting → user click → set cooldown; hashChanged && selfEmitting → own emit → skip cooldown. Portable across browser-plugin and navigation-plugin — no runtime-specific signals required.
| Plugin | Support | Mechanism |
|---|---|---|
@real-router/browser-plugin |
✅ Full |
state.context.url.hash claim + router.navigate({ hash, force, hashChange }) extension (#532) |
@real-router/navigation-plugin |
✅ Full | Same write API; richer direction / traverse signals available via state.context.navigation
|
@real-router/hash-plugin |
❌ Not supported |
# is the path delimiter — fundamentally incompatible. Detection: state.context.url === undefined → warn-once + NOOP |
@real-router/memory-plugin |
❌ NOOP | No URL → no hash. Same detection logic — safe in console / Ink runtimes |
| no URL plugin | ❌ NOOP | Nobody claims state.context.url → warn-once dev, NOOP |
Detection sequence (run at init; deferred via one-shot router.subscribe if the router isn't started yet):
const peekState = router.getState();
if (peekState && peekState.context?.url === undefined) {
console.warn(
"[real-router] scroll-spy: state.context.url is not claimed. " +
"Spy requires browser-plugin or navigation-plugin. Disabling."
);
return NOOP_INSTANCE;
}Once disabled via this detection, the spy is permanently silenced for the rest of its lifetime — subsequent transitions don't re-test the condition.
If the initial URL contains a hash without a matching id (e.g. /page#nonexistent from an old share link, OAuth callback, etc.):
- Browser native
location.hashauto-scroll has nothing to scroll to → viewport stays at top. -
IntersectionObserver.observe(anchor)queues an initial intersection notification asynchronously (per W3C IO spec). - First IO event fires in the next microtask without requiring a user scroll.
- Spy reads
state.context.url.hash === "nonexistent", picks topmost-visible (e.g."section-1"), emitsrouter.navigate(..., { hash: "section-1" }). - URL self-heals to the real anchor.
Verdict: automatic self-healing on mount, not "on first user scroll". Document references that grew stale survive transparently.
Spy emits a regular transition. Every subscriber on the bus — router.subscribe, subscribeLeave, lifecycle-plugin onStay / onNavigate, components via useRoute() / useRouteNode() — sees the spy's emit identically to a user-driven <Link hash> click. Data loaders, analytics trackers, and document.title syncs that should not fire on every scroll-tick need to filter:
router.subscribe(({ route }) => {
if (route.context.url?.hashChanged) return;
loadData(route.params);
});hashChanged: true is set on:
-
<Link hash>clicks routed throughnavigateWithHash(same-route different-hash, #532). - Browser back/forward across a hash-only diff (popstate / Navigation API).
- Scroll-spy emits.
Subscribers that need to react only to path/params changes filter via if (route.context.url?.hashChanged) return;. The flag is symmetric for all three sources — there is no semantic distinction at the bus level. This is intentional: the spy is a regular caller of router.navigate(...), not a privileged path.
lifecycle-plugin filtering uses the same idiom inside the route's onNavigate callback:
{
name: "users",
onNavigate: () => (toState) => {
if (toState.context.url?.hashChanged) return;
loadUsers(toState.params);
},
}For same-route same-params hash-only transitions, getTransitionPath returns empty toDeactivate / toActivate arrays — runGuards is a no-op. Guards (canActivate / canDeactivate) do not run on spy emits. onLeave / onEnter lifecycle hooks also don't fire (different routes required). Only onStay + onNavigate fire.
If a declarative reaction to hash-only transitions becomes a recurring need, that's an @real-router/hash-events-plugin discussion — out of scope for the current ship.
The spy uses a single IntersectionObserver instance for URL hash tracking. For non-URL use cases (lazy-loading images, infinite scroll, analytics in-view events), use a separate IntersectionObserver directly — the two don't conflict.
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLImageElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setLoaded(true);
observer.disconnect();
}
},
{ rootMargin: "200px" }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return <img ref={ref} src={loaded ? src : undefined} alt={alt} />;
}Bundle cost — the platform IntersectionObserver is shipped, not a wrapper.
Promotion-path candidate (RFC §11). Different routes may want different selectors or rootMargin — re-create the prop value reactively per current route. The framework's RouterProvider re-creates the spy when primitive deps change (see per-framework spec above).
const [routeName, setRouteName] = useState(
() => router.getState()?.name ?? ""
);
useEffect(() => router.subscribe(({ route }) => setRouteName(route.name)), [router]);
const scrollSpyOptions = (() => {
if (routeName === "article") return { selector: "[id]:is(h2,h3)" };
if (routeName === "guide") return { selector: "[id]:is(h2):not(.no-spy)" };
return undefined; // disable on this route
})();
return <RouterProvider router={router} scrollSpy={scrollSpyOptions}>{/* … */}</RouterProvider>;Live in examples/web/react/hash-examples/scroll-spy/src/App.tsx under the ?spy=per-route query mode.
A table-of-contents sidebar auto-highlights the active section as the user scrolls. No extra subscription — the <Link hash> active class already reads state.context.url.hash via the cached createActiveRouteSource in @real-router/sources.
const SECTIONS = [
{ id: "intro", label: "Introduction" },
{ id: "setup", label: "Setup" },
{ id: "usage", label: "Usage" },
];
function TocSidebar() {
return (
<nav className="toc">
{SECTIONS.map((s) => (
<Link
key={s.id}
routeName="article"
hash={s.id}
activeClassName="toc__link--active"
>
{s.label}
</Link>
))}
</nav>
);
}That's all — the spy emit updates state.context.url.hash, sources re-evaluate every <Link hash>, only the matching link gets the active class. No useEffect, no manual IntersectionObserver, no useState.
Live in examples/web/react/hash-examples/scroll-spy/src/components/TocSidebar.tsx.
-
hash-pluginruntime — spy detects missingstate.context.url, emits warn-once + NOOP. Usebrowser-pluginornavigation-pluginfor URL fragments. See hash-plugin for the full limitation list. -
Duplicate
idon a page (markdown rendering bug producing two<h2 id="examples">) — IO fires for both; selection rule picks topmost deterministically. Dev-onlyconsole.warnonce at detection. -
Invalid CSS selector —
scope.querySelectorAll(selector)throws → spy warns once, enters permanentsilencedstate. No retry on subsequent updates. -
No URL plugin / memory-plugin —
state.context.urlisundefined→ detection trips warn-once + NOOP. Safe in console / Ink / SSR runtimes. -
prefers-reduced-motion— orthogonal: the media query affectsscroll-behavior, notIntersectionObserver. Spy continues to work; opt-out via conditionalselector. -
Multi-router page (microfrontends) — each
RouterProvideropts in independently. Non-overlapping selectors → no conflict; overlapping → undefined order, unsupported. -
Strict Mode double-init (React 19) — mount → unmount → mount. Each mount creates a fresh
IntersectionObserver/MutationObserver; each unmount destroys them.destroy()is idempotent via adestroyedboolean.getTransitionSource(router)is per-router cached — spy reads its snapshot but doesn't calldestroy()(no-op for cached sources). -
SSR / Node —
typeof document === "undefined"short-circuit returnsNOOP_INSTANCEearly. No throw, no warning (production SSR — expected path).
| Aspect |
<Link hash> (Hash) |
anchorScrolling (Scroll-Restoration) |
createScrollSpy (this page) |
|---|---|---|---|
| Direction | URL → user-driven click | URL → scroll position (restore) | Scroll position → URL (write) |
| Owned by | URL plugins | shared/dom-utils/scroll-restore |
shared/dom-utils/scroll-spy |
| Trigger | Link click / popstate | URL fragment change after navigation |
IntersectionObserver notification |
| Side effect | URL state + active class | element.scrollIntoView() |
router.navigate({ hash, replace, force, hashChange }) |
| Tab UIs | ✅ Primary use case | ❌ Wrong tool (would jump-scroll on every tab click) | ❌ Wrong tool (no scroll involved) |
| Long-form docs | ✅ Manual section links | ✅ Anchor jump on initial / back | ✅ Primary use case (URL tracks scroll) |
-
Hash — URL fragment support,
<Link hash>,state.context.url, hash-aware active state. Foundation for the spy's write path. -
Scroll Restoration — opposite direction (URL → scroll). Composes cleanly with the spy:
state.transition.replace = trueon spy emits makes restoration skip them (no magnetic snap). -
View Transitions — sibling
shared/dom-utils/utility with the same adapter pattern. -
Navigation-Plugin — alternative URL plugin with richer
direction/traversesignals. -
examples/web/react/hash-examples/scroll-spy/— runnable demo: 12-section article, TOC sidebar, plugin (?plugin=browser) and spy-mode (?spy=per-route) switchers, 10 Playwright e2e scenarios. -
IMPLEMENTATION_NOTES.md— Scroll Spy via Forced Same-States Transition — design rationale (whyforce + hashChangeoverreplaceHistoryState, whyselfEmittingguard, anti-flicker architecture deep-dive).
- View-Agnostic Design
- Core Concepts
- Navigation Lifecycle
- Guards
- Plugin Architecture
- Hash Fragment Support
- Accessibility (A11y)
- Server-Side Rendering
- Data Loading
- Streaming SSR
- SSR Cancellation
- RSC Integration
- Testing
- Glossary
Tree-shakeable functions — import only what you need.
- add (addRoute)
- remove (removeRoute)
- replace (replaceRoutes)
- clear (clearRoutes)
- get (getRoute)
- has (hasRoute)
- update (updateRoute)
- get (getDependency)
- getAll (getDependencies)
- set (setDependency)
- setAll (setDependencies)
- has (hasDependency)
- remove (removeDependency)
- reset (resetDependencies)
For plugin authors, not for general use.
- makeState
- buildState
- buildNavigationState
- forwardState
- getForwardState
- setForwardState
- matchPath
- setRootPath
- getRootPath
- navigateToState
- addEventListener
- getRouteConfig
- getOptions
- addBuildPathInterceptor
- extendRouter
- getTree
- React Integration Guide
- Preact Integration Guide
- Solid Integration Guide
- Vue Integration Guide
- Svelte Integration Guide
- Ink (Terminal UI) Integration Guide
- Desktop (Electron, Tauri) Integration Guide
- useRouter
- useRoute
- useRouteNode
- useNavigator
- useRouteUtils
- useRouterTransition
- useRouteExit
- useRouteEnter
- useRouteStore (Solid only)
- useRouteNodeStore (Solid only)
SSR-feature subpath —
@real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.
- Lazy (Svelte only — dynamic component import)
- Await — read deferred SSR data by key
- Streamed — Suspense-style boundary
- ClientOnly — server fallback → client children swap
- ServerOnly — server children, removed after hydration
- HttpStatusCode — render-time HTTP status declaration
-
HttpStatusProvider — provides sink to descendant
<HttpStatusCode> - useDeferred — read deferred Promise by key