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();