Fixing CLS from Cookie Banners
Consent banners are one of the most common causes of poor Cumulative Layout Shift on otherwise well-built static sites. The page renders, the reader starts reading, and a third-party consent script arrives a second later and injects a banner at the top of the document — pushing the headline, the hero and everything below it down by 120 pixels. On mobile, that single shift often scores 0.15–0.25 on its own, well past the 0.1 "good" threshold, on every first visit.
The fix is rarely to remove consent handling; it is to change how the banner enters the page. This guide measures the CLS a consent banner caused on a static marketing and docs site, then applies three fixes in order of effectiveness. It is part of Cumulative Layout Shift Fixes for Static Sites.
Prerequisites
- A way to see layout shifts: the Performance panel in Chrome DevTools (Layout Shifts track) or the
web-vitalsattribution build, as in Measuring CLS in the Field with web-vitals.js. - Control over the banner's CSS, or the consent manager's configuration options.
- Test in a fresh profile or incognito window, because a stored consent choice hides the banner.
Step 1: Measure the Shift
Load the page with no stored consent, record in the Performance panel, and inspect the Layout Shift entries. The attribution build of web-vitals gives the same in the field:
import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
console.log(value.toFixed(3), attribution.largestShiftTarget, attribution.largestShiftTime);
});
// 0.182 "main > header.hero" 1240
On the test site, the banner was inserted as the first child of <body> at 1.24 s on mobile, 128 px tall, shifting the entire page. Lab CLS: 0.18. Field CLS p75 for first visits: 0.21; for returning visits (consent already stored, no banner): 0.02.
Fix 1: Make the Banner an Overlay
A position: fixed element does not affect the layout of other elements, so its appearance causes no layout shift. Pin the banner to the bottom of the viewport:
.consent-banner {
position: fixed;
inset: auto 0 0 0; /* bottom edge */
z-index: 50;
max-height: 40vh;
overflow: auto;
padding: 1rem;
background: var(--surface);
box-shadow: 0 -2px 12px rgb(0 0 0 / 0.12);
}
Most consent platforms offer a "bottom bar" or "floating" layout in configuration; use it rather than a top bar. If the vendor's CSS cannot be changed, a small override stylesheet loaded after it works. Two cautions: a fixed banner must not cover essential content without a way to dismiss it, and it must be reachable and operable by keyboard, with focus moved to it when it appears if it blocks interaction.
This change alone took lab CLS from 0.18 to 0.00 and field CLS p75 on first visits from 0.21 to 0.03.
Fix 2: Keep the Banner Off the LCP Path
A large banner can become the LCP element: on a small phone, a banner with three paragraphs of text may be the biggest block in the viewport, so LCP waits for the consent script. Keep the banner compact — a sentence and two buttons, with details behind a "Settings" link — and render it after the page's main content. On the test site, trimming the banner from 94 to 31 words kept the hero headline as the LCP element and moved mobile lab LCP from 2.4 s back to 1.6 s.
Fix 3: Load the Consent Script Late — but Not Too Late
Two parts of consent handling have different timing needs. The gate — the logic that stops analytics or marketing tags from running before consent — must run before those tags, which in practice means early. The banner UI can load after first paint. Many consent platforms load both as one 60–120 KB script in the <head>. Split them where possible:
<!-- in <head>: tiny gate, ~1 KB, no UI -->
<script>
window.consent = JSON.parse(localStorage.getItem('consent') || 'null');
window.consentReady = new Promise((r) => (window.__setConsent = r));
</script>
<!-- after content: banner UI, only when no stored choice -->
<script type="module">
if (!window.consent) import('/js/consent-banner.js');
</script>
Analytics and marketing tags wait on consentReady or read window.consent, so nothing fires before a choice exists. A self-built banner like this is about 3 KB; the vendor script it replaced was 88 KB and blocked the main thread for 140 ms on mobile. Whether a self-built banner meets your legal requirements is a question for whoever owns compliance; the performance pattern is the same with a vendor that offers an asynchronous loader. The broader approach to vendor scripts is in Third-Party Script Performance on Static Sites.
Measured Impact
| Measure (first visit, mobile) | Vendor top bar, sync script | Fixed bottom bar + split loading |
|---|---|---|
| Lab CLS | 0.18 | 0.00 |
| Field CLS p75 (first visits) | 0.21 | 0.03 |
| Lab LCP | 2.4 s | 1.6 s |
| Consent JavaScript before first paint | 88 KB | 1 KB |
| Total Blocking Time | 210 ms | 40 ms |
| Consent acceptance rate | 61% | 63% |
Other Banners, Same Problem
Consent banners are the most common late-inserted element, but not the only one. Promotional bars ("New: version 4 is out"), newsletter prompts, app-install banners and maintenance notices are usually added by a script after load and pushed into the top of the document for visibility — the exact pattern that caused the shift above. The same three rules apply to all of them. Render them at build time when the content is known in advance, so they are in the HTML from the first paint and cause no shift; a site-wide announcement can be a front-matter field or data file read by the layout. When they must be dynamic, use a fixed overlay or reserve their space with a placeholder of known height. And load their scripts after the main content, never in the head. On this site, an announcement bar injected by a marketing tool had been adding a further 0.06 of CLS on top of the consent banner; moving its text into a build-time data file removed it entirely and let the marketing team keep editing it through the CMS.
Returning Visitors and Consent Changes
Returning readers with a stored choice should see no banner and load no banner code at all; check this path too, because some vendors load their full script on every visit just to read the stored cookie. A "Cookie settings" link in the footer lets readers change their choice later by loading the banner UI on demand. On this site, returning visits loaded 1 KB of consent code instead of 88 KB, which also improved INP for returning readers because the vendor script had registered listeners on every page.
Pitfalls & Rollback
- Testing with consent stored. The banner never appears in your usual browser profile; test in a fresh profile.
- Top-of-page in-flow banners. They shift everything; use a fixed overlay.
- Overlays that trap readers. A fixed banner must be dismissible, keyboard accessible, and must not hide essential controls on small screens.
- Deferring the gate along with the UI. Tags must still wait for consent; only the UI should be late.
- Rollback: revert to the vendor's default layout in its configuration; the gate and banner split can be reverted independently.
Conclusion
A consent banner inserted into document flow was responsible for most of a static site's first-visit layout shift. Making it a fixed overlay removed the shift entirely, keeping it compact kept it from becoming the LCP element, and splitting a 1 KB consent gate from a lazily loaded banner UI took 87 KB of JavaScript off the critical path. First-visit CLS fell from 0.21 to 0.03 and LCP from 2.4 to 1.6 seconds, with consent rates unchanged.
FAQ
Why do cookie banners cause layout shift?
Many banners are inserted at the top of the page after the content has rendered, pushing everything down. Others are inserted at the bottom in normal document flow, shifting the footer and anything below. Both count as unexpected layout shifts.
Does a fixed-position banner cause CLS?
No. An element with position fixed or sticky that overlays content does not move other elements, so it contributes no layout shift. Its own appearance is not counted as a shift either.
Can the consent banner be the LCP element?
Yes, if it is the largest text block visible in the viewport when it appears, which is common on mobile. That makes LCP depend on the consent script's load time. Keep the banner compact or render it after the main content has painted.
Should the consent manager script load in the head?
Only the small part that must block other tags until consent is known. The banner UI can load later. Many vendors offer an asynchronous loader; self-hosting the UI or building a small banner yourself removes the third-party dependency entirely.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — the other sources of shift.
- Fixing CLS from Late-Loading Embeds — the same pattern for embeds.
- Loading Google Tag Manager Without Hurting INP — tags that wait on consent.
- Self-Hosting Analytics to Cut Third-Party Requests — reducing what needs consent.
- Fixing CLS from Sticky Headers and Anchor Links — another positioning-related shift.