The Problem
I ran a Lighthouse run on a client landing page last Thursday. LCP was 1.8s, INP was 92ms, both green. CLS was 0.41. That is a fail by a wide margin and the Vercel field data agreed — real users were getting a 0.31 75th-percentile CLS over the last 28 days. The page itself was solid. The hero was fixed-size, fonts were preloaded with font-display: optional, and every image had explicit width and height.
The culprit was the cookie consent banner. Cookiebot was being injected late, and when it slid up from the bottom of the viewport, it pushed the entire page content up to make room. CLS jumped 0.4 in a single frame and the field score never recovered.
If you are seeing CLS issues that show up only on first visit, only on mobile, or only for users in the EU, the consent banner is a strong suspect. Most consent tools ship with default CSS that animates the banner in from offscreen, which guarantees a layout shift unless you reserve space ahead of time.
Why It Happens
CLS measures unexpected layout shift. If an element appears on the page after first paint and pushes other elements around, every pixel of movement counts toward your score. Consent banners are perfect storm material: they load from a third-party script, they appear after the page has already painted, and they are usually positioned with position: fixed plus bottom: 0 plus a slide-in transform.
The slide-in transform is fine on its own. transform does not cause layout shift. The problem is what happens when the banner has position: relative (some plugins ship with this), or when the banner is rendered inline at the bottom of <body> with margin-top: auto in a flex column. Both push the content above it.
The other version of this bug shows up on mobile only. Some tools use position: sticky and reserve space with padding-bottom on <body>. When the script loads after first paint, the padding gets added late, which scrolls the page and counts as CLS.
A third variant: the consent script wraps the page in a modal overlay and dims everything behind it. If the overlay is rendered before the banner, the body's overflow: hidden change shifts the scrollbar, and that scrollbar shift counts on Windows browsers.
The Fix
Step 1: Confirm the banner is the source. Open Chrome DevTools, switch to the Performance Insights panel, and run a recording with "Capture screenshots" enabled. Filter the results to "Layout Shifts". You will see a strip with each shift event, the elements involved, and a timestamp. If the largest shift coincides with the banner appearing, you have your answer.
You can also script this. Drop the PerformanceObserver in your page's head, before any third-party script:
<script>
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.value > 0.05) {
console.log('Big shift:', entry.value, entry.sources?.map(s => s.node));
}
}
}).observe({ type: 'layout-shift', buffered: true });
</script>
The entry.sources array tells you exactly which DOM nodes moved. If the consent banner's container is in that list, it is the cause.
Step 2: Reserve space for the banner in the initial HTML. The fix is to render an empty placeholder of the correct height before the banner script runs. Then when the consent tool injects the real banner, it slots into the already-reserved space and nothing shifts.
For most consent tools the banner height is between 80px and 140px on mobile and 60px and 90px on desktop. Measure your specific banner first by inspecting it on a loaded page. Then add this to your global CSS:
:root {
--consent-banner-height: 96px;
}
@media (max-width: 768px) {
:root {
--consent-banner-height: 140px;
}
}
body {
padding-bottom: var(--consent-banner-height);
}
body.consent-given {
padding-bottom: 0;
}
Then on the consent tool's "accept" callback, toggle the class:
window.addEventListener('CookiebotOnAccept', () => {
document.body.classList.add('consent-given');
});
// Cookiebot also fires this on decline
window.addEventListener('CookiebotOnDecline', () => {
document.body.classList.add('consent-given');
});
For OneTrust and Iubenda the events are different (OneTrustGroupsUpdated and iubenda_consent_given respectively). Check your tool's docs.
Step 3: Position the banner with transform, not layout properties. If you can edit the banner's CSS, animate it in with transform: translateY(...) rather than changing bottom or top. Transform animations run on the compositor and do not trigger layout. Your banner CSS should look like:
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--consent-banner-height);
transform: translateY(100%);
transition: transform 200ms ease-out;
}
.cookie-banner.is-visible {
transform: translateY(0);
}
The banner starts offscreen via translateY(100%) and slides up by toggling a class. The reserved space at the bottom of the body never moves, so CLS stays at zero.
Step 4: Defer the script, but not too late. Most teams add defer or async to the consent script and call it done. That is fine for performance, but if the script loads after a user has already scrolled, the banner appearing mid-scroll counts as a shift even with reserved space (because the user's viewport position is now wrong).
The safer pattern is to inline a tiny stub that immediately sets the body class to "consent-pending" and reserves space, then load the full script with defer. That way the layout is correct from the first paint and the heavy script can take its time. Google's Web Vitals docs on CLS cover the broader pattern of reserving space for any late-loading content.
Step 5: Verify in the field, not just the lab. Lighthouse runs in a clean profile with no consent state, so it always shows the worst-case banner shift. Real users only see the banner once, then it is dismissed and CLS is zero on subsequent visits. Use Chrome's CrUX dashboard or the Web Vitals extension on a real device to confirm the fix actually moved the 75th-percentile number. Lab fixes that do not move field data are not fixes.
If you are also seeing INP issues from the consent script blocking the main thread, I covered that in the INP regression from GTM third-party tags fix earlier this month.
The Lesson
Consent banners are one of the top three causes of CLS regressions and the easiest to miss because they only fire on the first visit per user. Reserve space ahead of time, animate with transform only, and verify in the field. A 0.4 CLS becomes a 0.0 CLS with about ten lines of CSS once you know where to look.
If your Core Web Vitals are tanking and you cannot tell which third-party tag is the cause, I do this kind of audit fast — see my services.