Cookie Banner CLS: Consent Banners Without Layout Shift
Cookie banner CLS fixed: a top bar that pushes content scored 0.13 CLS on mobile, a fixed overlay 0.00. Lab and field data from 131 banners, plus CSS patterns.
A cookie banner causes Cumulative Layout Shift (CLS) when it's inserted into the normal document flow (usually at the top of <body>) after the page has painted, pushing everything below it down. The fix is to render the banner out of flow: position: fixed at the bottom, a modal overlay, or a slot reserved in the initial HTML. Animate it with transform, never with top, bottom or height.
We measured the difference directly. In a controlled lab test on a 390×844 mobile viewport, the same banner scored 0.129 CLS when inserted as a top bar and 0.000 when fixed to the bottom. That's the gap between failing and passing Google's 0.1 "good" threshold, from one CSS property. In the field, 41 of 131 real consent banners (31.3%) produced measurable layout shift during load, and 5 pushed the page past 0.1 on their own.
This guide covers the mechanics, the lab benchmark, the field data, copy-paste CSS patterns, and an A/B script that measures how much CLS your own banner adds.
Why Consent Banners Shift Layout
The Layout Instability API reports a layout shift whenever a visible element that already existed changes position between frames. Two banner patterns trigger it:
- Push-down insertion. The CMP script runs after first paint (it's usually async and often waits for a geolocation or configuration request), then prepends a bar to
<body>or addspadding-topto make room. Every element below moves down by the banner's height. The banner isn't the shifting element. Your content is. - Self-shifting animation. The banner is fixed, but it slides in by animating
bottom: -200px → 0, or it grows as its text and buttons load. A fixed element changing position or size between frames is itself a layout shift.
Two things don't count. A newly inserted element appearing doesn't count by itself, and neither does movement done with CSS transform. That's why the fixes below work. Google's CLS documentation defines the metric, and Optimize CLS covers injected content in general.
Timing makes it worse. CMP scripts typically show the banner 1–3 seconds after load, when the user is already reading, so the shift is visible and annoying as well as counted.
Lab Benchmark: 8 Banner Patterns, Same Page
We built one test page (header, H1, eight sections of text) and injected the same banner 800 ms after load using eight different patterns. Each page was loaded five times in Chromium at BugViso's mobile profile (390×844, DPR 3) and at 1366×900 desktop, recording every layout shift until 2.5 s after load. The numbers are the median load CLS:
| Banner pattern | Mobile CLS | Desktop CLS | Verdict |
|---|---|---|---|
Top bar inserted in flow (body.prepend) | 0.1294 | 0.0801 | ❌ Poor on mobile |
Fixed bar + body { padding-top } | 0.1386 | 0.0858 | ❌ Poor on mobile |
Fixed bottom bar, slides in via bottom | 0.0089 | 0.0052 | ⚠️ Small but avoidable |
| Fixed bottom bar | 0.0000 | 0.0000 | ✅ |
Fixed bottom bar, slides in via transform | 0.0000 | 0.0000 | ✅ |
| Full-screen modal overlay | 0.0000 | 0.0000 | ✅ |
Top bar in a reserved slot (min-height in initial HTML) | 0.0000 | 0.0000 | ✅ |
| No banner (control) | 0.0000 | 0.0000 | Control |
Three takeaways:
- Mobile suffers more. The same banner height is a bigger fraction of a short viewport, and layout shift scores scale with how much of the viewport moved. The top bar that cost 0.08 on desktop cost 0.13 on mobile.
- "Fixed + padding-top" is just as bad. Some CMPs and themes keep the banner fixed but add body padding so it doesn't cover the header. Adding the padding moves everything, exactly like inserting the bar.
- Reserving space works for top bars. If a top bar is a design requirement, put an empty container with a fixed
min-heightin the server-rendered HTML and render into it.
Field Data: 131 Real Consent Banners
On 9 October 2026 we loaded 370 homepages (a random global sample of 196 and a random sample of 174 EU country-domain sites, both described in our pre-consent tracking study) in Chromium at 1366×900, with no interaction, recording layout shifts and their source elements for 6 seconds after load. 131 showed a visible consent banner.
| Measurement (131 sites with a visible banner) | Result |
|---|---|
Banner is position: fixed | 128 (97.7%) |
| Placement: bottom / full-screen / top / centre | 80 / 31 / 11 / 9 |
| Any layout shift attributed to the banner | 41 (31.3%) |
| …of which the banner itself moved or resized | 32 (24.4%) |
| Banner-attributed shift ≥ 0.05 | 9 (6.9%) |
| Banner-attributed shift ≥ 0.1 (fails on its own) | 5 (3.8%) |
| Median page CLS: sites with a banner vs without | 0.016 vs 0.005 |
Almost every production banner is already fixed, so the classic push-down bar is now the minority. The common problem in the field is the second mechanism: fixed banners that move or resize while they load, from slide-in animations on bottom, from banner text that loads in after the box is drawn, or from a "flat" banner that changes height when its fonts arrive. Each one is usually small (median 0.01), but on sites that also have other shifts, the banner accounted for roughly half of the page's measured CLS.
On BugViso's mobile profile (390×844), 50 of 188 global homepages showed a banner, and the median banner covered 46% of the screen. Two of those 50 caused a banner-attributed shift of 0.1 or more on their own.
Copy-Paste Patterns That Don't Shift
Pattern 1: fixed bottom bar with transform entrance
/* ✅ Out of flow; enters with transform (compositor-only, never a layout shift) */
.consent-bar {
position: fixed;
inset: auto 0 0 0; /* bottom, full width */
z-index: 1000;
transform: translateY(100%);
transition: transform .35s ease-out;
max-height: 50vh; /* never cover the whole small screen */
overflow: auto;
}
.consent-bar.is-visible { transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .consent-bar { transition: none; } }/* ❌ Animating a layout property: the banner itself shifts on every frame */
.consent-bar { position: fixed; bottom: -200px; transition: bottom .4s; }
.consent-bar.is-visible { bottom: 0; }Pattern 2: modal dialog
<!-- ✅ Overlay: nothing underneath moves -->
<dialog id="consent" aria-labelledby="consent-title">
<h2 id="consent-title">Cookie settings</h2>
<p>We use analytics cookies only if you allow them.</p>
<button value="reject">Reject all</button> <button value="accept">Accept all</button>
</dialog>
<script>document.getElementById('consent').showModal();</script>A native <dialog> opened with showModal() renders in the top layer, traps focus and is announced as a dialog by screen readers. That's good for accessibility as well as CLS. Give "Reject" the same prominence as "Accept". Several European regulators have objected to banners that make refusing harder than accepting.
Pattern 3: reserved top slot (when a top bar is required)
<!-- ✅ Space exists from the first paint; the CMP renders into it -->
<div id="consent-slot" style="min-height:96px"></div>
<header>…</header>Collapse the slot only after the user decides, when there's recent input. Shifts within 500 ms of a tap or click are excluded from CLS (hadRecentInput).
Pattern 4: stop the banner from growing after it appears
/* ✅ Fix the box size before its content arrives */
.consent-bar { min-height: 120px; contain: layout; }
.consent-bar button { min-width: 7.5rem; min-height: 44px; } /* labels load late, the box doesn't change */If your banner text is fetched from the CMP's configuration endpoint, the box size changes when the text lands. Give it a min-height that fits the longest translation you serve.
Configuring a hosted CMP
Hosted CMPs inject their own markup, so you control layout through their configuration and through CSS overrides on their container IDs (such as #onetrust-banner-sdk or #CybotCookiebotDialog). Check your CMP's layout options for a bottom bar or modal layout instead of a top bar or "push down" layout, and turn off slide or grow animations if you can't make them transform-based. After any change, re-measure: CMP updates can change the injected markup.
Measure Your Own Banner: A/B Script
Whole-page CLS mixes the banner with everything else. This script loads your page several times with the consent platform and several times with its hosts blocked, and reports the difference plus the elements that moved.
#!/usr/bin/env python3
"""banner_cls.py: how much layout shift does the cookie banner cause? A/B the page with the CMP blocked.
Usage:
pip install playwright && playwright install chromium
python3 banner_cls.py https://example.com [--runs 5] [--wait 6] [--desktop] [--block extra-cmp-host.com]
Loads the page --runs times normally and --runs times with requests to known consent-platform
hosts aborted, on a 390x844 mobile viewport (or 1366x900 with --desktop), and reports the median
load CLS of each plus the biggest shifts with the elements that moved. CLS here is the sum of all
layout shifts without recent input until --wait seconds after load (default 6), so values are comparable
between the two variants, not identical to the field metric.
"""
import argparse, asyncio, statistics
from playwright.async_api import async_playwright
CMP_HOSTS = ["cookiebot.com", "onetrust.com", "cookielaw.org", "usercentrics.eu", "didomi.io", "trustarc.com",
"iubenda.com", "termly.io", "cookieyes.com", "osano.com", "consensu.org", "quantcast.com",
"axeptio.eu", "complianz.io", "civicuk.com", "civiccomputing.com", "privacy-mgmt.com",
"privacy-center.org", "cmp.inmobi.com", "consentmanager.net"]
OBSERVE = """
window.__shifts = [];
new PerformanceObserver((l) => { for (const e of l.getEntries()) { if (e.hadRecentInput) continue;
window.__shifts.push({v: e.value, t: Math.round(e.startTime), who: (e.sources || []).map(s => {
const n = s.node; if (!n || !n.tagName) return '#text';
return n.tagName.toLowerCase() + (n.id ? '#' + n.id : '') + (typeof n.className === 'string' && n.className ? '.' + n.className.trim().split(/\\s+/)[0] : '');
})}); } }).observe({type: 'layout-shift', buffered: true});
"""
async def once(browser, url, mobile, block, wait):
vp = {"viewport": {"width": 390, "height": 844}, "device_scale_factor": 3, "is_mobile": True, "has_touch": True} \
if mobile else {"viewport": {"width": 1366, "height": 900}}
ctx = await browser.new_context(**vp)
await ctx.add_init_script(OBSERVE)
page = await ctx.new_page()
if block:
await page.route("**/*", lambda route: route.abort() if any(
(route.request.url.split("/")[2].endswith(h)) for h in block) else route.continue_())
await page.goto(url, wait_until="load", timeout=45000)
await page.wait_for_timeout(wait * 1000)
shifts = await page.evaluate("window.__shifts")
await ctx.close()
return sum(s["v"] for s in shifts), shifts
async def main(a):
async with async_playwright() as pw:
browser = await pw.chromium.launch()
res = {}
for label, block in (("with banner", None), ("CMP blocked", CMP_HOSTS + a.block)):
runs = [await once(browser, a.url, not a.desktop, block, a.wait) for _ in range(a.runs)]
res[label] = runs
cls = [round(c, 4) for c, _ in runs]
print(f"{label:12s} median CLS {statistics.median(cls):.4f} runs {cls}")
await browser.close()
worst = max(res["with banner"], key=lambda r: r[0])[1]
print("\nlargest shifts in the worst 'with banner' run:")
for s in sorted(worst, key=lambda s: -s["v"])[:5]:
print(f" {s['v']:.4f} at {s['t']} ms moved: {', '.join(s['who'][:3])}")
diff = statistics.median(c for c, _ in res["with banner"]) - statistics.median(c for c, _ in res["CMP blocked"])
print(f"\nbanner-attributable CLS ≈ {diff:+.4f}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("url")
ap.add_argument("--runs", type=int, default=5)
ap.add_argument("--desktop", action="store_true")
ap.add_argument("--wait", type=int, default=6, help="seconds to keep observing after load")
ap.add_argument("--block", nargs="*", default=[], help="extra CMP hosts to block")
asyncio.run(main(ap.parse_args()))Real output for a European bank's homepage (mobile, 3 runs each, 9 October 2026):
with banner median CLS 0.1157 runs [0.1157, 0.1311, 0.099]
CMP blocked median CLS 0.0787 runs [0.0787, 0.0787, 0.0786]
largest shifts in the worst 'with banner' run:
0.0669 at 2763 ms moved: div#mainContent
0.0465 at 2608 ms moved: div.menu-area, a.logo, div#onetrust-banner-sdk.otFlat
0.0069 at 1759 ms moved: div#onetrust-banner-sdk.otFlat
0.0053 at 2424 ms moved: div#onetrust-banner-sdk.otFlat
banner-attributable CLS ≈ +0.0371The banner pushed this page from 0.079, already close to the line, to a median of 0.116, failing on mobile. The shift log shows both mechanisms at once: the banner box moving several times as it settled, and the main content moving when the banner arrived. If your site serves a different banner by region, run the script from that region. A blocked CMP can also change what other scripts load, so treat the difference as an estimate and confirm with the shift log.
How BugViso Shows Banner-Related Layout Shift
BugViso measures CLS in a real Chromium load of every scanned page, on desktop and in a separate mobile emulation pass on an iPhone 16 Pro profile (390×844). The Core Web Vitals section reports CLS against Google's 0.1 and 0.25 thresholds, alongside LCP, INP and TBT. The same scan's Privacy & Compliance audit detects the consent platform on the page from 15+ CMP signatures.
That pairing is the useful part. One report shows that the page has, say, a OneTrust banner and a mobile CLS of 0.12, so the banner is the first suspect to test with the A/B script. The scan never clicks the banner, because clicking "Accept" would set the tracking cookies the privacy audit reports, so the CLS you see includes whatever the banner does on a first visit.
You can scan a page for CLS and consent-platform detection. For shift sources beyond banners, see the CLS debugging cheatsheet and how to fix CLS from dynamically injected content. The Core Web Vitals and speed checks page lists every metric BugViso measures.
Edge Cases
- Field CLS vs lab CLS. Real users who return to your site have already chosen, so they never see the banner. First-visit-heavy traffic (ads, social) suffers most. If your Search Console CLS is worse on landing pages than on deep pages, the banner is a prime suspect.
- Consent walls block interaction, not layout. A full-screen overlay doesn't shift anything itself, but closing it can trigger lazy-loaded content above the fold. The 500 ms
hadRecentInputwindow usually covers this. - Reduced motion. Respect
prefers-reduced-motionand skip the slide-in. Even a transform-based animation is unnecessary for users who opt out. - Covering content isn't free. A bottom bar over half the screen doesn't shift layout, but it hides content. Cap its height (
max-height: 50vh) on small screens.
FAQ
Do cookie banners affect Core Web Vitals?
Yes, through CLS. A banner inserted into the page flow, or one that moves and resizes as it loads, adds layout shift. In our lab test a push-down top bar alone scored 0.13 on mobile, above the 0.1 "good" threshold. A fixed overlay scored 0.
Does a fixed-position cookie banner cause CLS?
Not by appearing. Fixed elements are out of flow, so nothing else moves. It does cause CLS if it slides in by animating top/bottom or changes size after it's visible. Use transform for animation and a min-height for the box.
Can a cookie banner be the LCP element?
Yes. Banners with a lot of text can be the largest element painted, especially on mobile, which delays LCP until the CMP script has run. Keep banner text short and the box smaller than your hero content.
Should the banner be at the top or the bottom?
For CLS, either works if it's out of flow or reserved. A bottom bar or a modal is the simplest way to guarantee zero shift. A top bar needs a reserved slot.
Conclusion
A cookie banner should never move your content: render it fixed or in a reserved slot, animate it with transform, and confirm with an A/B load, or run a BugViso scan to see the page's CLS next to the consent platform that's on it.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.