Fixing CLS From Dynamic Table of Contents Widgets
A table of contents is one of the most useful things a long documentation page can have, and one of the most common sources of layout shift on documentation sites. The typical implementation is a script that runs after load, collects the article's h2 and h3 elements, and inserts a list — at the top of the article on mobile, in a right-hand sidebar on desktop. Either placement moves the content: the inline list pushes the article down by its full height, and a sidebar that was zero width until the script ran narrows the article column and reflows every line.
This guide fixes TOC-related shifts on an Eleventy documentation site with 1,600 pages, where field data attributed most poor CLS on docs pages to the TOC. It moves the TOC to build time, reserves the sidebar, and changes the scroll-spy and collapsible behaviour so they never move layout. It belongs to Cumulative Layout Shift Fixes for Static Sites.
Symptoms
Field CLS from the web-vitals library with attribution, two weeks on docs pages, as in Measuring CLS in the Field With web-vitals.js:
| Measure | Mobile | Desktop |
|---|---|---|
| CLS p75 | 0.19 | 0.14 |
| Page views with CLS > 0.1 | 41% | 33% |
Largest shift target: article after TOC insert | 62% of poor views | 18% |
Largest shift target: .toc resize while scrolling | 9% | 51% |
Two different problems: on mobile, the inline TOC inserted above the article; on desktop, the sidebar TOC expanding and collapsing nested lists during scroll.
Step 1: Build the TOC at Build Time
The TOC script had existed because the site's Eleventy setup did not expose headings to templates. A small filter does it after Markdown rendering, using the IDs that markdown-it-anchor already adds:
// eleventy.config.js
eleventyConfig.addFilter('toc', (html) => {
const items = [...html.matchAll(/<h([23]) id="([^"]+)"[^>]*>(.*?)<\/h\1>/g)]
.map(([, level, id, text]) => ({ level: +level, id, text: text.replace(/<[^>]+>/g, '') }));
return items;
});
{% set toc = content | toc %}
{% if toc.length > 2 %}
<nav class="toc" aria-labelledby="toc-title">
<h2 id="toc-title" class="toc-title">On this page</h2>
<ol>{% for i in toc %}<li class="toc-l{{ i.level }}"><a href="#{{ i.id }}">{{ i.text }}</a></li>{% endfor %}</ol>
</nav>
{% endif %}
The list is now in the HTML from the first frame. In other generators the same data is built in: Astro's render() returns headings, Hugo has .Fragments.Headings and .TableOfContents, and the documentation frameworks all generate it statically.
Step 2: Put the Mobile TOC Where It Cannot Push Content
On mobile the TOC was inline above the article. Even rendered at build time, a long TOC pushed the article's first paragraph — the LCP element on most pages — below the fold. It became a collapsed <details> element with a fixed one-line height when closed:
<details class="toc-mobile">
<summary>On this page</summary>
<!-- same list -->
</details>
Opening it is a reader action, so the resulting movement is excluded from CLS (shifts within 500 ms of input are not counted), and it no longer delays LCP.
Step 3: Reserve the Desktop Sidebar
On desktop, the article and sidebar are a grid. The sidebar column used auto width, so it had zero width until the TOC appeared. Now the grid defines the column whether or not a TOC exists:
.docs-layout { display: grid; grid-template-columns: minmax(0, 1fr) 15rem; gap: 3rem; }
@media (max-width: 64em) { .docs-layout { grid-template-columns: minmax(0, 1fr); } }
.toc { position: sticky; top: 5rem; max-height: calc(100vh - 6rem); overflow-y: auto; }
Pages with fewer than three headings render an empty <aside> so the article column keeps the same width on every page — which also stopped the column jumping when navigating between pages.
Step 4: Scroll-Spy Without Layout Changes
The desktop TOC highlighted the current section and expanded its h3 children while collapsing the others. Each expand and collapse changed the sidebar's height — harmless on its own, since the sidebar was sticky — but the nested lists also changed the width of items through bold text and scrollbars appearing and disappearing, and the browser counted each as a shift. Two changes:
- Always show
h3items; highlight instead of expanding. The list's height is constant. - Mark the active item with properties that do not affect layout:
.toc a { border-inline-start: 2px solid transparent; padding-inline-start: 0.75rem; }
.toc a[aria-current='true'] { border-color: var(--color-accent); color: var(--color-text); }
The border was already there, transparent, so turning it coloured moves nothing. Bold text was removed from the active state because the site's variable font changes width with weight; if bold is wanted, font-variation-settings with a width-compensated axis or a text-shadow "faux bold" avoid reflow.
The scroll-spy itself uses an IntersectionObserver on the headings and sets aria-current on the matching link — about 0.8 KB. Fixing CLS from Sticky Headers and Anchor Links covers the related problem of anchor links landing under a sticky header.
Why Lab Tests Missed It
Lighthouse reported CLS of 0.02 on the same docs pages that showed 0.19 in the field. Two things explain the gap, and both are common to TOC shifts. On a fast lab connection the TOC script ran before Lighthouse's first screenshot, so the insertion happened before the article was visible and counted for little; on real mid-range phones the script ran after first paint, while the reader could see the text. And Lighthouse does not scroll, so the scroll-spy shifts on desktop never happened in the lab at all. Reproducing the problem needed throttling (DevTools CPU 6× slowdown with "Slow 4G") and a Playwright script that scrolled through the page while a PerformanceObserver collected layout-shift entries:
const shifts = await page.evaluate(async () => {
const out = [];
new PerformanceObserver((l) => l.getEntries().forEach((e) => !e.hadRecentInput && out.push(e.value))).observe({ type: 'layout-shift', buffered: true });
for (let y = 0; y < document.body.scrollHeight; y += 400) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); }
return out;
});
That script now runs on five representative docs pages in CI and fails if the summed shift exceeds 0.05, which covers both the load-time and scroll-time cases that Lighthouse cannot see.
Accessibility of the New TOC
Moving to a static list also fixed two accessibility issues the script had. The old TOC was a <div> of links with no name, so screen readers announced it as an unlabelled list; the new one is a <nav> labelled by its "On this page" heading, which appears in the landmarks list. And the old scroll-spy moved keyboard focus to the active link on scroll, which yanked screen reader users around the page; the new one only sets aria-current, which screen readers announce when the reader reaches the link and never moves focus.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| CLS p75, mobile docs | 0.19 | 0.03 | web-vitals RUM |
| CLS p75, desktop docs | 0.14 | 0.01 | web-vitals RUM |
| Views with CLS > 0.1 | 37% | 3% | web-vitals RUM |
| LCP p75, mobile docs | 2.4 s | 1.9 s | web-vitals RUM |
| TOC JavaScript | 4.1 KB | 0.8 KB (scroll-spy only) | Build output |
LCP improved on mobile because the first paragraph was no longer pushed below the fold by the inline TOC.
Pitfalls & Rollback
- Building the TOC in the browser. Every generator can do it at build time.
- Inline TOCs above the article on mobile. Collapse them so they cannot push content.
- Auto-width sidebar columns. Reserve the column on every page.
- Bold or expanding active states. Change colours and borders only.
- Rollback: the build-time TOC and the layout CSS are separate commits; the old script can return for pages that need it while the reserved column prevents its worst shift.
Conclusion
A table of contents shifts docs pages when it is built after load, inserted above the article, placed in an unreserved column, or animated by scroll-spy that changes sizes. Generating it at build time, collapsing it on mobile, reserving a fixed sidebar column and limiting the active state to colour took mobile CLS on docs pages from 0.19 to 0.03 and desktop from 0.14 to 0.01.
FAQ
Why does a table of contents cause layout shift?
When it is built in the browser after the page loads, by a script that reads the page's headings and inserts a list. If the list appears inline above the article, or in a sidebar whose width is not reserved, everything after it moves. Collapsible or sticky TOCs that change height as the reader scrolls can add more shifts later in the visit.
Can the table of contents be generated at build time?
Yes, with every major generator. Astro's render function returns headings, Hugo provides TableOfContents and Fragments, Eleventy has plugins that extract headings from rendered content, and Docusaurus, Starlight and VitePress all build the TOC statically. A build-time TOC is in the HTML from the first frame and cannot shift anything.
Does scroll-spy highlighting cause layout shift?
Not if it only changes colours, backgrounds or an outline. It causes shift when the active item becomes bold in a font with different metrics, gains a border that adds width, or when a nested list expands to show the active section's subheadings. Use properties that do not affect layout, or reserve the space.
Do shifts that happen during scrolling count toward CLS?
Yes. CLS counts unexpected layout shifts throughout the visit, grouped into session windows, and the largest window is reported. Shifts within 500 milliseconds of a click, tap or key press are excluded, but scrolling is not an input that excludes them.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — all common CLS causes.
- CLS from Client-Side Rendered Components — the general pattern behind this fix.
- Fixing CLS from Sticky Headers and Anchor Links — TOC links landing under headers.
- Sidebar Navigation in Astro and Eleventy — the left-hand navigation counterpart.
- Fixing Heading Order Problems in Markdown Content — the headings the TOC is built from.
Filed underFixes