Github

Toc

<nav class="toc" aria-label="On this page"> around a nested ol or ul. Links are # ids on headings. Nested lists indent with padding, not a class named left. There is no wrapper. Optional <p class="toc-title"> above the list (not an h2). Title and links are 0.8rem. No script: authors may set aria-current="true" on the matching link, or copy the spy snippet below.

Two levels

Max depth 2. A nested list under each top item.

<nav class="toc" aria-label="On this page">
	<ol>
		<li>
			<a href="#two-levels" aria-current="true">Two levels</a>
			<ol>
				<li><a href="#two-levels-markup">Markup</a></li>
			</ol>
		</li>
		<li>
			<a href="#three-levels">Three levels</a>
			<ol>
				<li><a href="#three-levels-nested">Nested lists</a></li>
			</ol>
		</li>
	</ol>
</nav>

Markup

Paste the nested list. There is no wrapper.

Three levels

Max depth 3. A separate tree, not one mega-list in the first example.

<nav class="toc" aria-label="On this page">
	<ol>
		<li>
			<a href="#three-levels">Three levels</a>
			<ol>
				<li>
					<a href="#three-levels-nested">Nested lists</a>
					<ol>
						<li><a href="#three-levels-current">Current section</a></li>
					</ol>
				</li>
			</ol>
		</li>
		<li>
			<a href="#two-levels">Two levels</a>
			<ol>
				<li><a href="#two-levels-markup">Markup</a></li>
			</ol>
		</li>
	</ol>
</nav>

Nested lists

A second nested ol. Indent is padding-inline-start.

Current section

Optional aria-current="true" on the matching link. The primitive does not highlight on scroll.

Title

Optional p.toc-title as the first child of nav.toc. Not an h2, and not a class named left, right, or center. The gap under the title is larger than li + li spacing. This docs site prepends “On This Page” when it parks a page TOC that has no title. Preview TOCs on this page stay in the article.

<nav class="toc" aria-label="On this page">
	<p class="toc-title">On This Page</p>
	<ol>
		<li><a href="#toc-title">Title</a></li>
		<li><a href="#current-section">Current section</a></li>
	</ol>
</nav>

Current section

The primitive is still copy-paste HTML. To highlight the heading in view, observe the scroll pane (here .docs-main) against the TOC hrefs and set aria-current="true" on the matching link — nested h3 links too. Disconnect the observer on route change. Do not use a class named underline; the current mark is already styled. This docs site runs that spy on parked TOCs.

function spy_toc(root, toc) {
	const links = [...toc.querySelectorAll('a[href^="#"]')];
	const pairs = [];
	for (const link of links) {
		const id = decodeURIComponent((link.hash || '').slice(1));
		if (!id)
			continue;
		const heading = root.querySelector('#' + CSS.escape(id));
		if (heading)
			pairs.push({ heading, link });
	}
	if (!pairs.length)
		return () => {};

	function set_current(link) {
		for (const item of links)
			item.removeAttribute('aria-current');
		if (link)
			link.setAttribute('aria-current', 'true');
	}

	function apply() {
		const top = root.getBoundingClientRect().top;
		let current = pairs[0].link;
		for (const { heading, link } of pairs) {
			if (heading.getBoundingClientRect().top - top <= 16)
				current = link;
		}
		set_current(current);
	}

	const observer = new IntersectionObserver(apply, {
		root,
		rootMargin: '0px 0px -70% 0px',
		threshold: 0
	});

	for (const { heading } of pairs)
		observer.observe(heading);

	function on_click(event) {
		const link = event.target.closest('a[href^="#"]');
		if (!link || !toc.contains(link))
			return;
		set_current(link);
	}

	toc.addEventListener('click', on_click);
	root.addEventListener('scroll', apply, { passive: true });
	apply();

	return () => {
		observer.disconnect();
		root.removeEventListener('scroll', apply);
		toc.removeEventListener('click', on_click);
	};
}

const toc = document.querySelector('nav.toc');
const root = document.querySelector('.docs-main');
const stop = spy_toc(root, toc);
// On route change: stop();