Fixing CLS from Sticky Headers and Anchor Links

Sticky headers are nearly universal on documentation sites, and they are a quiet source of two problems. The first is layout shift: headers that switch to position: fixed on scroll, shrink to a compact version after 100 pixels, or change height when the web font arrives all move the content beneath them, and because scrolling is not an excluded input, those shifts count toward Cumulative Layout Shift. The second is usability: a reader follows a link to #configuration, the browser scrolls the heading to the top of the viewport, and the sticky header covers it.

This guide measures both on a documentation site, fixes the shift with stable heights and position: sticky, and fixes anchor links with scroll-padding. It is part of Cumulative Layout Shift Fixes for Static Sites.

Prerequisites

Why Lab Tests Miss It

Lighthouse measures CLS during page load without scrolling, so a header that shifts content on scroll scores 0 in the lab. Field data tells a different story. On the test site, lab CLS for the docs template was 0.01; field CLS p75 was 0.14, and attribution pointed at main shifting by 56 px at a median of 4.2 seconds after load — the moment readers began scrolling and the header's JavaScript switched it from static to fixed.

import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution: a }) => navigator.sendBeacon('/api/rum', JSON.stringify({
  cls: value, target: a.largestShiftTarget, time: Math.round(a.largestShiftTime),
})));
// { cls: 0.14, target: "main.content", time: 4180 }
A header switching to fixed on scroll Two frames. Before scrolling, a 56 pixel header sits in normal flow above the content. When the reader scrolls past a threshold, a script sets position fixed; the header leaves the flow and the content jumps up 56 pixels, recorded as a layout shift. A third frame shows position sticky, where the header stays in flow and the content does not move. static → fixed on scroll: the content jumps before scroll header (in flow) content position: fixed → shift content jumps up 56 px position: sticky → no shift content stays put Field CLS p75 on the docs template: 0.14 with the scroll script, 0.02 with sticky
Sticky positioning keeps the header's space in the layout, so nothing below it has to move.

Fix 1: Use position: sticky With a Fixed Height

Replace JavaScript-driven switching with CSS:

:root { --header-h: 56px; }
.site-header {
  position: sticky;
  top: 0;
  z-index: 40;
  height: var(--header-h);        /* fixed height, never content-driven */
  background: var(--surface);
}
html { scroll-padding-top: calc(var(--header-h) + 12px); }

position: sticky keeps the header in normal flow, so its space is reserved from the first paint and nothing moves when it starts sticking. A fixed height prevents a different class of shift: headers whose height depends on their content change size when the web font swaps in, or when a search box or banner inside them loads. The font case is covered in Eliminating Layout Shift from Web Fonts.

Fix 2: Shrink Without Shifting

If the design calls for a header that becomes more compact on scroll, animate it with transform rather than changing height or padding. A transform does not affect layout, so it cannot cause a layout shift:

.site-header .inner { transition: transform 150ms ease; transform-origin: top left; }
.site-header[data-compact] .inner { transform: scale(0.86); }

Keep the header's layout box the same height in both states; only the visual scale changes. A tiny script toggles data-compact from an IntersectionObserver on a sentinel element at the top of the page, avoiding scroll listeners entirely. Respect prefers-reduced-motion by disabling the transition.

With a sticky header, jumping to #configuration scrolls the heading to the top edge of the viewport, underneath the header. scroll-padding-top on the scroll container — the root element for page scrolling — tells the browser to leave that much space above anchor targets. It applies to same-page links, links from other pages with a fragment, and keyboard focus scrolling. The older technique of adding scroll-margin-top to every heading also works but is easy to miss on new elements.

Test it with a deep link from another page and with the table of contents, including on narrow screens if the header height changes at a breakpoint — in that case set --header-h per breakpoint so the padding follows.

Anchor target position with and without scroll-padding Two viewports after following a link to a heading. Without scroll-padding, the heading is scrolled to the very top and hidden behind the 56 pixel sticky header. With scroll-padding-top of 68 pixels, the heading stops just below the header and is fully visible. Following a link to #configuration no scroll-padding header covers heading scroll-padding-top: 68px header ## Configuration One CSS declaration fixes every anchor on the site
The fix belongs on the root element once, not on each heading.

Other Sticky Elements: Sidebars, Tables of Contents and Tabs

Headers are the most visible case, but documentation pages usually have two or three more sticky elements, and each can shift content in similar ways.

Sticky sidebars and "on this page" lists. Sidebars positioned with position: sticky; top: var(--header-h) are safe as long as their width is fixed; a sidebar whose width is set by its longest label changes width when the web font loads and shifts the main column sideways. Give sidebars an explicit width, and let long labels wrap or truncate.

Active-section highlighting. A table of contents that bolds the current section on scroll can change line heights and push later entries down — a shift inside the sticky element that still counts. Highlight with colour or a border, never with font weight that changes metrics.

Tab bars that become sticky. Code-sample tabs that stick below the header on long pages must reserve their own height with the same fixed-height approach, and anchor links need scroll-padding-top to include both heights.

Mobile headers with a hide-on-scroll pattern. Hiding the header when the reader scrolls down and showing it when they scroll up is popular on phones. Implement it with transform: translateY(-100%), never by changing display or height, so the layout stays still.

Field CLS p75 on the docs template over eight weeks A line of weekly field CLS p75. It sits around 0.14 for four weeks, drops to about 0.05 the week the header fix ships, and settles at 0.02 after the sidebar width and table-of-contents highlight fixes the following week. The 0.1 good threshold is marked. Weekly field CLS p75, docs template 0 0.15 0.1 good threshold header fix sidebar + TOC fixes wk 1 wk 8 web-vitals attribution beacons; lab CLS was 0.01 throughout
The header accounted for most of the shift; the sidebar and table of contents for the rest.

Measured Impact

The docs site, four weeks of field data before and after:

MeasureScroll-switched fixed headerSticky, fixed height, scroll-padding
Field CLS p75, docs template0.140.02
Pages with "poor" CLS in Search Console4860
Lab CLS (Lighthouse)0.010.01
Header JavaScript2.1 KB + scroll listener0.3 KB (IntersectionObserver)
Anchor-link headings hidden (manual test of 40 links)400

The lab number never moved, which is the lesson: scroll-driven shifts only show up in field data.

Pitfalls & Rollback

  • Trusting lab CLS. Load tests do not scroll. Use field attribution for headers, and add a scripted scroll to your Lighthouse user flow if you want a lab signal as well.
  • Height from content. A header sized by its contents shifts when fonts or widgets load; set an explicit height.
  • Animating height or padding. Use transform for compact states.
  • Forgetting breakpoints. If the header is taller on mobile, scroll-padding-top must follow.
  • Sticky inside overflow: hidden ancestors. Sticky positioning stops working inside a scroll container; check ancestors if the header does not stick.
  • Announcement bars above the header. A dismissible banner that sits above a sticky header changes the header's offset when it closes. Overlay the banner or reserve its space until the reader dismisses it, and update --header-h in the same frame.
  • Hard-coded offsets in JavaScript. Smooth-scroll helpers that subtract a fixed pixel value drift out of sync with the CSS. Read the offset from the custom property, or drop the helper and rely on scroll-padding-top.
  • Rollback: the change is CSS plus removing a scroll script; restoring the previous stylesheet reverts it.

Conclusion

Sticky headers cause layout shift when they leave normal flow on scroll or change height after first paint, and they hide anchor targets unless the page knows their height. position: sticky with a fixed height, transform for compact states and scroll-padding-top on the root solved both on a documentation site: field CLS p75 fell from 0.14 to 0.02, 486 pages left Search Console's "poor" list, and every deep link landed with its heading visible — all while lab CLS, which never scrolls, reported no problem before or after.

FAQ

Why does a sticky header cause layout shift?

When a header switches from static to fixed positioning on scroll, it leaves normal flow and the content below jumps up by the header's height. Headers that shrink or grow on scroll, or change height when a web font loads, shift content in the same way.

Do shifts during scrolling count toward CLS?

Shifts that happen within 500 milliseconds of a discrete user input such as a tap or key press are excluded, but scrolling is not an excluded input. A header that changes layout while the reader scrolls produces shifts that count.

Set scroll-padding-top on the html element to the header's height. The browser then scrolls anchor targets so they stop below the header, for both same-page links and links from other pages.

Is position sticky better than position fixed for headers?

Usually. A sticky element stays in normal flow, so switching it on causes no jump, and its space is reserved automatically. Fixed headers need a matching padding on the body to avoid overlapping content.