The problem with long documents

Long documents ask a lot of a reader. Somewhere around the third screen of scrolling, the sense of where am I quietly evaporates — the page becomes an undifferentiated ribbon of text, and the only way back to a half-remembered paragraph is a slow, thumb-cramping scroll. Print never had this problem: a book has a spine, a thickness, a physical sense of progress you hold in your hand.

An in-page navigation — a table of contents that lives beside the text and quietly tracks where you are — gives some of that back. Think of the faint stack of lines on the right edge of a Notion page: they are a map of the document and a you-are-here marker at once. This piece is a specification for building that pattern well.

No, but why it felt like that for real?

— a reader, lost on the fourth screen
A long page with no map: every paragraph looks like every other paragraph.
A long page with no map: every paragraph looks like every other paragraph.

Section detection

The navigation should never be a hand-maintained list. Every time someone edits the article, a manual list drifts out of sync — a heading gets renamed, a section is reordered, and the map now lies. Instead, the component reads the document at load time and builds itself from what it finds.

Concretely: it queries for headings that opt in with a known selector — say h2[data-section] for major sections and h3[data-section] for subsections. Anything matching becomes an entry; anything that doesn't is ignored. Authors add a section simply by writing a heading, and the map updates for free.

  • Major sections are h2[data-section] — they render as long lines.
  • Subsections are h3[data-section] — shorter, indented lines.
  • Ordinary headings without the attribute stay out of the map.

Giving each heading an ID

Anchor links need a target. As the component walks the headings, any heading missing an id gets one generated from a slug of its text — lowercased, spaces to hyphens, punctuation stripped. Existing IDs are left untouched so that hand-authored anchors and inbound links keep working.

Each qualifying heading becomes one navigation entry, with a stable ID for linking.
Each qualifying heading becomes one navigation entry, with a stable ID for linking.

Anchor linking

Each entry in the nav is a real anchor pointing at its heading's ID. That means it works before a single line of JavaScript runs — the plainest possible fallback. On top of that, clicking an entry does two things.

First, it scrolls the target heading into view smoothly rather than jumping, so the reader keeps their spatial bearings. Second — preferred but optional — it updates the URL hash without a full page reload, so the location bar reflects the current section and the page becomes deep-linkable and shareable at the section level.

Active state tracking

This is the part that makes the navigation feel alive. As the reader scrolls, the entry for the section currently in view — or the one closest to the top of the viewport — is highlighted. When they cross into the next section, the highlight moves with them.

Under the hood this is an IntersectionObserver watching every heading, with a root margin that biases the "active" line toward the top of the screen. It's cheap, it doesn't thrash the main thread on every scroll frame, and it degrades to a no-op where the API is unavailable.

The highlight tracks the section closest to the top as the reader moves through the page.
The highlight tracks the section closest to the top as the reader moves through the page.

Responsive behavior

On a wide screen there's room for a vertical list in a column of its own — the shape this page uses. As the viewport narrows and the two columns can no longer sit side by side, the navigation shouldn't simply vanish.

Here it collapses into a horizontal, swipeable strip that sits above the article: same entries, same active tracking, just laid on their side. On the very smallest screens it could equally become a compact dropdown. The rule is that the map is always reachable, never in the way.

Accessibility

A navigation that only responds to a mouse is only half-built. Because each entry is a native anchor, it's reachable with the Tab key and activates on Enter (and Space) with no extra wiring. A visible focus ring makes the keyboard position obvious.

The surrounding nav is labelled so assistive technology announces it as "On this page." The currently active entry carries aria-current="true", so a screen-reader user hears which section is in view — the same information the sighted highlight conveys.

  • Tab moves between entries; Enter or Space jumps to a section.
  • The active entry is marked with aria-current="true".
  • Motion honours prefers-reduced-motion for readers who ask for less.

Putting it together

None of these pieces is exotic on its own. What makes the pattern worth specifying is the discipline of doing all of them at once: auto-built so it never lies, anchor-linked so it works without script, scroll-aware so it stays oriented, responsive so it survives small screens, and accessible so everyone gets the same map. Get those right and the reader stops thinking about navigation entirely — which is the whole point.