Eliminating CLS from Cookie Consent Banners
A consent banner is the one element on the page that must render before anything else is allowed to run, is usually delivered by a third party, and is injected into the DOM after first paint. That combination makes it one of the largest single contributors to layout shift on European traffic. This guide sits under CLS Reduction Strategies and covers the placement decision that removes the shift entirely, the cases where you cannot make that decision, and how to prove the fix in field data.
The good news is that this is one of the few CLS problems with a genuinely complete fix rather than a mitigation, and it does not require the vendor’s cooperation.
Prerequisites
- Layout shift attribution in the field so you can confirm the banner is the source — the reporter is in Debugging Web Vitals with the Attribution Build.
- Knowledge of whether your consent platform renders inline, as an overlay, or lets you choose.
- Agreement from whoever owns consent that the banner may be restyled. The legal requirement concerns visibility and choice, not layout mechanics — but confirm it rather than assume it.
- A test path that reliably shows the banner: a fresh profile or cleared storage, since a returning visitor never sees it.
How to remove the shift
Step 1 — Confirm the banner is the shift source
import { onCLS } from 'web-vitals/attribution';
onCLS((metric) => {
const a = metric.attribution;
const source = a.largestShiftTarget || '';
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'CLS',
value: Math.round(metric.value * 1000) / 1000,
source,
// A consent banner shift happens early and involves a selector that
// usually carries the vendor name — tag it so it can be queried.
isConsent: /consent|cookie|cmp|gdpr|onetrust|cookiebot/i.test(source),
at: Math.round(a.largestShiftTime),
route: window.__route,
}));
});
The at field matters as much as the selector. Consent shifts cluster in a narrow band a few hundred milliseconds after first paint, which distinguishes them from image shifts (later, scattered) and font swaps (earlier, tight).
Step 2 — Prefer an overlay over an inline banner
An element that is position: fixed is out of normal flow. It cannot push anything, so it cannot shift anything, no matter when it arrives. That single property change removes the entire problem.
/* Bottom-anchored overlay: no reflow of the document, no shift score. */
.consent-banner {
position: fixed;
inset: auto 0 0 0;
z-index: 9000;
max-height: 60vh;
overflow-y: auto;
/* Keep the page usable underneath: the banner should not cover the LCP
element, or it will change which element the browser picks. */
}
/* Give sticky footers and chat launchers room so nothing is obscured. */
body:has(.consent-banner) .sticky-footer { padding-bottom: 8rem; }
If the platform renders inline markup you cannot control, the same result is achievable by taking its container out of flow yourself — the vendor renders into a node, and that node is yours.
Step 3 — When it must be inline, reserve the space
Some legal reviews require the banner to be part of the document rather than an overlay, and some designs place it above the header deliberately. In that case, reserve its height from the first paint.
<!-- Server-rendered, always present, collapses only after a decision. -->
<div id="consent-slot" data-state="pending"></div>
<style>
/* Reserve the tallest realistic height, per breakpoint. Measure the real
banner and round up; an over-reservation costs whitespace, an
under-reservation costs a shift. */
#consent-slot[data-state="pending"] { min-height: 132px; }
@media (min-width: 768px) { #consent-slot[data-state="pending"] { min-height: 96px; } }
#consent-slot[data-state="resolved"] { min-height: 0; }
</style>
Two rules make this work. The slot must be in the server-rendered HTML, not created by the CMP, and the height must be measured from the real banner at each breakpoint rather than guessed. A reserved 96 px that is filled by a 148 px banner still shifts — by less, which is the worst outcome, because it looks fixed in a lab run and is not.
Step 4 — Collapse without shifting
The other half of the problem is the dismissal. Removing a reserved slot after the user accepts moves everything up, and that shift counts unless it happens within 500 ms of the input.
// The collapse must be attributable to the click. Do it synchronously in
// the handler, not in an async callback after a network round trip.
acceptButton.addEventListener('click', () => {
slot.dataset.state = 'resolved'; // synchronous: excluded via hadRecentInput
banner.remove();
// The consent write can be async; the layout change must not be.
void persistConsent({ analytics: true, marketing: true });
});
Shifts within 500 ms of a user interaction carry hadRecentInput and are excluded from CLS. Collapsing inside the click handler is therefore free; collapsing after the vendor’s network call returns 900 ms later is not.
Step 5 — Keep the banner off the LCP element
An overlay that covers the hero changes which element the browser considers largest and visible, which can move LCP to a different element with a different timing. Anchor the banner to the bottom, or ensure it occupies less of the viewport than the hero does.
Verifying it works
- The
isConsentshare of shift sources should fall to near zero. This is the direct test and it needs no interpretation. - p75 CLS on the affected geography should drop within a day or two. Segment by country — traffic that never sees the banner will dilute the change to invisibility.
- Check the LCP element did not change. Compare the attributed LCP element before and after; an overlay that covers the hero silently swaps it.
- Test the dismissal path explicitly with a fresh profile, watching the layout-shift entries in DevTools. A collapse that happens outside the input window still scores.
- Confirm consent still works. Restyling must not change what the CMP records — verify a denial is still honoured end to end, as described in Integrating Consent Mode with RUM Beacons.
Budgeting the banner as a third party
Once the shift is gone the banner is still one of the heaviest scripts on the page, and it is the one script that cannot be deferred — everything downstream waits for its decision. That makes its own execution cost a hard floor on how fast the page can be for European traffic.
Three numbers are worth tracking per CMP, and they are the numbers to take to a vendor review:
| Measure | Where it comes from | Reasonable ceiling |
|---|---|---|
| Script transfer size | Resource Timing on the CMP origin | 40 KB gzipped |
| Main-thread time during init | Long Animation Frames attribution | 80 ms on a mid-tier device |
| Time from load to decision available | Your own mark around the callback | 300 ms |
The third one is the one that matters most and is almost never measured. Everything gated on consent — analytics, personalisation, and your own RUM beacon if you gate it — is blocked until that decision resolves. A CMP that takes 900 ms to reach a decision delays the start of measurement itself, which quietly biases every number you collect toward sessions that waited around long enough. The gating pattern is covered in Integrating Consent Mode with RUM Beacons.
Edge cases and gotchas
- The banner is only shown to some traffic. Aggregate CLS across all sessions will barely move even when the fix is perfect. Always segment to the population that sees it.
- Height varies with text length. Localised copy is longer in some languages, and a reserved height tuned in English shifts in German. Reserve per locale or use an overlay.
- The CMP re-renders on preference change. Opening the preferences panel after acceptance is another injection, and if it happens without a recent interaction it scores.
- Scroll locking causes a scrollbar shift. Setting
overflow: hiddenonbodywhile the banner is open removes the scrollbar, shifting content horizontally by its width. Compensate withscrollbar-gutter: stable. - A/B tests on the banner. Two variants with different heights mean the reserved slot is right for one of them. Reserve for the taller.
- Server-side geo gating is not perfect. Sessions that receive the banner unexpectedly are the ones that shift, and they are invisible in an aggregate that assumes the gate worked.
FAQ
Does a fixed-position banner really produce no CLS?
Correct — an element out of normal flow cannot displace other content, so it contributes no shift score. The only caveat is that it must not cause a scrollbar to appear or disappear, which does move layout.
Is it legal to change the banner from inline to overlay?
The legal requirements concern informed consent, visibility of choices, and equal prominence of accept and reject. Layout mechanics are not usually part of that, but the change should be reviewed by whoever owns consent rather than shipped unilaterally.
Why does my CLS still show the banner after I made it fixed?
Usually because a wrapper element is still in flow, or because the vendor injects a spacer element alongside the banner. Inspect the DOM after injection rather than trusting the CSS you wrote.
Should the collapse be animated?
Animating height with a transition still moves content, and the shift is only excluded if it starts within 500 ms of the input. Fading out an overlay is safe; animating an inline slot closed is not worth the risk.
Related
- CLS Reduction Strategies — the metric, its session windows and every other shift source.
- Reducing CLS from Dynamic Ad Injections — the same reservation technique for a harder case.
- Integrating Consent Mode with RUM Beacons — making sure the consent signal still gates collection correctly.