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).

Shift score by source on European trafficOn traffic that sees the banner it is usually the largest single source, because it inserts at the top of the document and pushes everything below it.Shift score by source on European trafficSum of shift values attributed to each element, 30 daysConsent banner0.12Hero image0.04Font swap0.03Promotional strip0.0100.050.10.150.2
On traffic that sees the banner it is usually the largest single source, because it inserts at the top of the document and pushes everything below it.

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.

p75 CLS on European traffic through the changeThe banner moved to a fixed-position overlay on day four. The remaining 0.023 comes from images and fonts and is a separate piece of work.p75 CLS on European traffic through the changeDaily p75, sessions that saw the banner00.050.10.150.2Good 0.1d1d2d3d4d5d6d7p75 CLS
The banner moved to a fixed-position overlay on day four. The remaining 0.023 comes from images and fonts and is a separate piece of work.

Verifying it works

  1. The isConsent share of shift sources should fall to near zero. This is the direct test and it needs no interpretation.
  2. 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.
  3. Check the LCP element did not change. Compare the attributed LCP element before and after; an overlay that covers the hero silently swaps it.
  4. 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.
  5. 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.

When the banner arrives relative to first paintThe banner arrives 620 ms after content is on screen, which is exactly why an inline injection shifts everything the user was already reading.When the banner arrives relative to first paintConsent platform load and injection, 4G mobile0 ms400 ms800 ms1,200 ms1,600 msDocument and CSS — 520 msFirst paint — 180 msCMP script fetch and init — 620 msBanner injected — 280 msshift lands at 1,320 ms
The banner arrives 620 ms after content is on screen, which is exactly why an inline injection shifts everything the user was already reading.

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: hidden on body while the banner is open removes the scrollbar, shifting content horizontally by its width. Compensate with scrollbar-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.

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.