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:

MeasureMobileDesktop
CLS p750.190.14
Page views with CLS > 0.141%33%
Largest shift target: article after TOC insert62% of poor views18%
Largest shift target: .toc resize while scrolling9%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.

Share of poor-CLS page views by largest shift source, mobile and desktop combined Horizontal bars. Article pushed down by the inline TOC insert 41 percent. TOC nested list expanding during scroll 29 percent. Article column narrowed by the sidebar appearing 17 percent. Other causes such as images and embeds 13 percent. What moved on poor-CLS docs views Inline TOC inserted 41% Nested list expands on scroll 29% Sidebar appears 17% Other 13%
web-vitals attribution, two weeks of docs page views with CLS above 0.1.

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.

Article column behaviour with an auto-width and a reserved sidebar column Two panels. Before, the grid's sidebar column had automatic width, so the article column was full width at first paint and narrowed by 240 pixels when the script inserted the TOC, reflowing every line. After, the sidebar column is reserved at 15 rem from the first frame, the TOC is in the HTML, and the article width never changes. Desktop layout before and after Auto-width sidebar article full width at first paint TOC inserted by script column narrows 240 px, text reflows CLS 0.09 per view Reserved column 15 rem column from first frame TOC in static HTML article width constant CLS 0 from layout
The reserved column costs nothing on pages without a TOC except unused whitespace, which the design already had.

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 h3 items; 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.

How the table of contents works after the fixes Four steps. At build time the generator extracts h2 and h3 headings and renders the list into the HTML. On mobile the list sits in a closed details element one line high. On desktop it sits in a reserved 15 rem sticky column. While scrolling, an IntersectionObserver sets aria-current on the active link, which only changes a border colour. The TOC after the fixes Build-time list from rendered headings Mobile: details closed, one line Desktop: reserved column sticky, 15 rem Scroll-spy colour change only
No step inserts, resizes or reflows anything after the first frame.

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

MeasureBeforeAfterTool
CLS p75, mobile docs0.190.03web-vitals RUM
CLS p75, desktop docs0.140.01web-vitals RUM
Views with CLS > 0.137%3%web-vitals RUM
LCP p75, mobile docs2.4 s1.9 sweb-vitals RUM
TOC JavaScript4.1 KB0.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.

Filed underFixes