Skip to content

Scroll Spy

olegivanov edited this page Jun 2, 2026 · 1 revision

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 RouterProvider prop (or provideRealRouter option in Angular).

Why This Is Not a Plugin

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.

Opt In

React / Preact

<RouterProvider
  router={router}
  scrollSpy={{ selector: "[id]:is(h2,h3)" }}
>
  {/* ... */}
</RouterProvider>

Solid

<RouterProvider
  router={router}
  scrollSpy={{ selector: "[id]:is(h2,h3)" }}
>
  {/* ... */}
</RouterProvider>

Read once on mount — Solid onMount is non-reactive, consistent with scrollRestoration and viewTransitions.

Vue

<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.

Svelte

<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.

Angular

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.

Options

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; rootMargin is the user-facing dial.
  • rAF + 150 ms trailing debounce — coalesces IO bursts so a fast scroll produces ≤ 1 router.navigate per debounce window.
  • MutationObserver re-observe debounced 250 ms — catches anchor additions / id renames in client-rendered docs.
  • 500 ms cooldown fallback — when scrollend event isn't supported (older Safari), the safety timeout lifts the gate after a user-driven smooth scrollIntoView.

Behavior Matrix

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.

Anti-flicker Architecture

Three composing gates inside createScrollSpy:

1. isTransitioning gate

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.

2. coolingDown gate

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:

  • scrollend event on the scroll container or window (Baseline 2026: Chrome 114+, Firefox 109+, Safari 17+).
  • 500 ms fallback timeout for browsers that ship scrollIntoView({ behavior: "smooth" }) but never fire scrollend (older Safari).

Either signal clears coolingDown. IO events during the smooth animation are silently dropped.

3. selfEmitting guard

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 Compatibility

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.

Self-healing on Init

If the initial URL contains a hash without a matching id (e.g. /page#nonexistent from an old share link, OAuth callback, etc.):

  1. Browser native location.hash auto-scroll has nothing to scroll to → viewport stays at top.
  2. IntersectionObserver.observe(anchor) queues an initial intersection notification asynchronously (per W3C IO spec).
  3. First IO event fires in the next microtask without requiring a user scroll.
  4. Spy reads state.context.url.hash === "nonexistent", picks topmost-visible (e.g. "section-1"), emits router.navigate(..., { hash: "section-1" }).
  5. 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.

Filtering Hash-only Transitions

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 through navigateWithHash (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.

Recipes

Recipe A — Custom IntersectionObserver alongside spy

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.

Recipe B — Per-route scrollspy config

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.

Recipe C — TOC sidebar (~30 LOC)

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.

Known Limitations / Edge Cases

  • hash-plugin runtime — spy detects missing state.context.url, emits warn-once + NOOP. Use browser-plugin or navigation-plugin for URL fragments. See hash-plugin for the full limitation list.
  • Duplicate id on a page (markdown rendering bug producing two <h2 id="examples">) — IO fires for both; selection rule picks topmost deterministically. Dev-only console.warn once at detection.
  • Invalid CSS selectorscope.querySelectorAll(selector) throws → spy warns once, enters permanent silenced state. No retry on subsequent updates.
  • No URL plugin / memory-pluginstate.context.url is undefined → detection trips warn-once + NOOP. Safe in console / Ink / SSR runtimes.
  • prefers-reduced-motion — orthogonal: the media query affects scroll-behavior, not IntersectionObserver. Spy continues to work; opt-out via conditional selector.
  • Multi-router page (microfrontends) — each RouterProvider opts 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 a destroyed boolean. getTransitionSource(router) is per-router cached — spy reads its snapshot but doesn't call destroy() (no-op for cached sources).
  • SSR / Nodetypeof document === "undefined" short-circuit returns NOOP_INSTANCE early. No throw, no warning (production SSR — expected path).

Comparison with Related Features

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)

See Also

  • 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 = true on 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 / traverse signals.
  • 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 (why force + hashChange over replaceHistoryState, why selfEmitting guard, anti-flicker architecture deep-dive).

Navigation

Home


Concepts


Getting Started


Router Methods

Lifecycle

Navigation

State

URL & Path

Events


Standalone API

Tree-shakeable functions — import only what you need.

Routes — getRoutesApi(router)

Dependencies — getDependenciesApi(router)

Guards — getLifecycleApi(router)

Plugin Infrastructure — getPluginApi(router)

For plugin authors, not for general use.

SSR / SSG


React / Preact / Solid / Vue / Svelte Integration

Provider

Hooks

Components

SSR Components & Hooks

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

DOM Utilities

Patterns


Subscription Layer (@real-router/sources)


Reactive Streams (@real-router/rx)


Plugins

Browser Plugin

Navigation Plugin

Hash Plugin

Memory Plugin

Lifecycle Plugin

Preload Plugin

Logger Plugin

Persistent Params

SSR Data

RSC Server

Validation

Search Schema

Utilities


Reference

Types

Error Codes

Clone this wiki locally