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
- A site with a sticky or fixed header.
- Field CLS data with attribution — see Measuring CLS in the Field with web-vitals.js — because scroll-driven shifts barely appear in a Lighthouse load test.
- The header's markup and CSS.
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 }
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.
Fix 3: Anchor Links That Land Below the Header
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.
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.
Measured Impact
The docs site, four weeks of field data before and after:
| Measure | Scroll-switched fixed header | Sticky, fixed height, scroll-padding |
|---|---|---|
| Field CLS p75, docs template | 0.14 | 0.02 |
| Pages with "poor" CLS in Search Console | 486 | 0 |
| Lab CLS (Lighthouse) | 0.01 | 0.01 |
| Header JavaScript | 2.1 KB + scroll listener | 0.3 KB (IntersectionObserver) |
| Anchor-link headings hidden (manual test of 40 links) | 40 | 0 |
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
heightorpadding. Usetransformfor compact states. - Forgetting breakpoints. If the header is taller on mobile,
scroll-padding-topmust follow. - Sticky inside
overflow: hiddenancestors. 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-hin 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.
How do I stop anchor links hiding headings under a sticky header?
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.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — all the common shift sources.
- Fixing CLS from Cookie Banners — the other positioning fix.
- Eliminating Layout Shift from Web Fonts — why header height changes on font swap.
- View Transitions on Multi-Page Static Sites — keeping the header still across pages.
- Sidebar Navigation in Astro and Eleventy — the sticky sidebar next to the header.