Skip to content

ScrollSpy: deterministic active-section detection#42557

Open
mdo wants to merge 1 commit into
v6-devfrom
mdo/scrollspy-rewrite
Open

ScrollSpy: deterministic active-section detection#42557
mdo wants to merge 1 commit into
v6-devfrom
mdo/scrollspy-rewrite

Conversation

@mdo

@mdo mdo commented Jun 25, 2026

Copy link
Copy Markdown
Member

Problem

ScrollSpy chose the active section with a scroll-direction flag + a sequential offsetTop tiebreaker that mutated state while walking IntersectionObserver entries. IO delivers entries in no guaranteed order, so when several sections shared the viewport (e.g. the docs right-rail TOC) the highlight was wrong. Coarse defaults also left stale gaps, smooth scroll suppressed the URL hash, focus wasn't managed, and ids with dots/special characters threw.

Change

Deterministic, geometry-based detection:

  • The active section is the deepest one whose top has scrolled to/above an activation line near the top of the scroll root, read fresh from getBoundingClientRect() on each rAF-throttled scroll — order-independent, immune to section height, no ties. At the bottom the last section wins; above the first, nothing is active.
  • scrollend (with a scroll-idle fallback) settles the final state, restores the URL hash via history.replaceState, and moves focus to the target section after a smooth-scroll click.
  • Section ids are escaped via parseSelector, so dotted/special-character ids no longer throw.
  • New topMargin option positions the activation line (default 12%); rootMargin/threshold/target/smoothScroll remain.

Supersedes #41016 (its ratio-based selection mis-handles tall sections). Fixes #37858, #39198, #39248, #36387, #40526.

Verification

  • Full unit suite green (1111), lint clean. Added regression tests: multiple-sections-visible determinism, bottom-forces-last, special-character-id escaping, and the settle→hash/focus behavior.
  • Verified end-to-end in a real browser against the built bundle (multiple visible → correct single highlight, bottom → last, smooth-scroll click → URL hash + focus).

The docs right-rail TOC uses Bootstrap ScrollSpy directly, so it's fixed transitively.

Replace the scroll-direction + sequential-offsetTop heuristic (which
depended on IntersectionObserver entry order and mis-highlighted when
several sections shared the viewport) with a deterministic geometric
model: the active section is the deepest one whose top has scrolled to
or above an activation line near the top of the scroll root, read fresh
from getBoundingClientRect on each (rAF-throttled) scroll. At the bottom
the last section wins; above the first nothing is active.

Also: settle on scrollend (with a scroll-idle fallback) to restore the
URL hash via replaceState and move focus to the target after a
smooth-scroll navigation; escape section ids via parseSelector so dotted
/special-character ids no longer throw; add a topMargin option for the
activation line.

Supersedes #41016. Fixes #37858, #39198, #39248, #36387, #40526.
@mdo mdo requested a review from a team as a code owner June 25, 2026 18:36
@mdo mdo mentioned this pull request Jun 26, 2026
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant