Skip to content

6.3 — CLS playbook

Module 6 · Lesson 3 · 🟢 Foundational · ~30 min

The cheapest vital to fix, the most damaging to trust, and the one you should fix first because it buys you organizational credibility for the hard work later.

Most commerce sites can go from 0.25 to under 0.05 in under a week.


Step 1 — Find the shifts

In the field

import { onCLS } from 'web-vitals/attribution';

onCLS(({ value, attribution }) => {
  reportMetric({
    name: 'CLS',
    value,
    attribution: {
      target: attribution.largestShiftTarget,     // the selector that moved
      time: attribution.largestShiftTime,         // when, relative to load
      shiftValue: attribution.largestShiftValue,
      loadState: attribution.loadState,
      source: attribution.largestShiftSource?.node,
    },
  }, { attribution: true });
});
SELECT page_type,
       JSON_VALUE(attribution, '$.target') AS culprit,
       COUNT(*) AS n,
       AVG(CAST(JSON_VALUE(attribution, '$.shiftValue') AS FLOAT64)) AS avg_shift
FROM vitals WHERE name = 'CLS' AND value > 0.1
GROUP BY 1, 2 ORDER BY n DESC LIMIT 20;

In the lab

DevTools → Rendering panel → "Layout Shift Regions" — shifts flash blue as they happen. This is the fastest way to see the problem. Throttle to Slow 3G so late‑loading content is visible.

Performance panel → Experience track — each shift is a red bar. Click it to see the affected elements and the score.

Console observer — logs every shift with its sources:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as any[]) {
    if (entry.hadRecentInput) continue;        // user-initiated, excluded from CLS
    console.log('shift', entry.value.toFixed(4), entry.sources?.map((s: any) => ({
      node: s.node,
      from: s.previousRect,
      to: s.currentRect,
    })));
  }
}).observe({ type: 'layout-shift', buffered: true });

Test with a slow connection and a cold cache. CLS on a warm cache and fast wifi is often zero, which is why teams believe they don't have a problem. Your users on 4G do.


The eight commerce sources

1. Images without dimensions

// ❌ Height is 0 until the image loads, then content jumps
<img src={product.image} alt={product.name} />

// ✅ next/image reserves space from width/height
<Image src={product.image} alt={product.name} width={800} height={1067} sizes="…" />

// ✅ Or aspect-ratio on the container with `fill`
<div className="relative aspect-[3/4]">
  <Image src={product.image} alt={product.name} fill sizes="…" />
</div>

For images from a CMS with unknown dimensions, store the aspect ratio at ingest and pass it through. "We don't know the size" is a data problem, not a CSS problem.

2. Web fonts

The fallback and the web font have different metrics → text reflows on swap. → 2.2 Fontsnext/font with adjustFontFallback.

3. Banners inserted at the top

Cookie consent, promo bars, free‑shipping notices, and app‑install prompts injected after paint push the entire page down. This is often the single largest shift on a commerce site.

// ❌ Inserted into the flow → pushes everything down
{showPromo && <div className="bg-black p-3 text-white">Free shipping over $50</div>}

// ✅ Option A: always render, reserve the height, hide with visibility
<div className="h-11 overflow-hidden" aria-hidden={!showPromo}>
  {showPromo && <PromoBar />}
</div>

// ✅ Option B: overlay instead of pushing (best for consent banners)
<div className="fixed inset-x-0 bottom-0 z-50">
  <ConsentBanner />
</div>

// ✅ Option C: decide server-side from a cookie, so it's in the initial HTML
// app/layout.tsx (or a dynamic hole under PPR)
const dismissed = (await cookies()).get('promo_dismissed')?.value === '1';
{!dismissed && <PromoBar />}

Option C is the right answer for anything driven by a cookie. If the server knows, render it server‑side and there's no shift at all.

4. Ads and third‑party embeds

/* Reserve the largest expected creative size */
.ad-slot {
  min-height: 250px;      /* 300×250 is the common unit */
  contain: layout;        /* isolate its effect on the rest of the page */
}
@media (min-width: 1024px) {
  .ad-slot { min-height: 90px; }   /* leaderboard */
}

If the ad might not fill, reserving space costs you a gap — but a gap is free and a shift is not. Negotiate a fixed size with the ad ops team; "responsive" ad slots are a CLS generator.

5. A/B test variants swapping in

The classic anti‑flicker pattern: hide <body>, wait for the experiment SDK, reveal. This trades CLS for a catastrophic LCP delay.

<!-- ❌ The worst pattern in web performance. Hides the page for up to 4s. -->
<style>body { opacity: 0 !important; }</style>
<script>
  setTimeout(() => document.body.style.opacity = '1', 4000);
</script>

→ Server‑side or edge assignment (8.5). The variant should be decided before the HTML is generated, so there's nothing to swap.

6. Client‑side price/stock hydration

// ❌ "From $—" becomes "$89.00" after hydration; width changes; the buy button moves
{price ? <span>{formatPrice(price)}</span> : <span>From $—</span>}

// ✅ Server-render the value (PPR dynamic hole), or reserve the width
<span className="inline-block min-w-[6ch] tabular-nums">{price ? formatPrice(price) : ''}</span>

tabular-nums is genuinely useful here — it makes all digits the same width, so a price changing from $9.00 to $89.00 doesn't reflow neighbors.

7. Infinite scroll and dynamic insertion

// ❌ Inserting content above the current scroll position shifts everything
setProducts((prev) => [...newItems, ...prev]);

// ✅ Append below, and reserve space for the loading row
<div className="grid …">{products.map(…)}</div>
<div className="h-24">{isLoading && <LoadingRow />}</div>

Never insert above the viewport. If you must (a "new items" feed), use the scroll anchoring behavior the browser provides by default — and don't disable it with overflow-anchor: none.

8. Late‑loading badges, reviews, and personalization

Anything that appears after paint inside existing content. Reserve space or render server‑side.

// ❌ Star rating appears 400ms late, pushing the price down
<h3>{product.name}</h3>
<ReviewStars productId={product.id} />     {/* client fetch */}
<Price value={product.price} />

// ✅ Reserve the row height whether or not there's a rating
<h3>{product.name}</h3>
<div className="h-5">
  <ReviewStars productId={product.id} />
</div>
<Price value={product.price} />

The general rules

1. Everything that loads late gets reserved space, sized to its typical content.
2. Never insert content above the current viewport position.
3. Prefer overlays to insertions for anything that appears after load.
4. Anything the SERVER knows should be rendered by the server.
5. Skeletons must be the same size as what replaces them.
6. Animate transform/opacity, never layout properties.

Rule 4 is the one that eliminates whole categories at once. Cookie‑driven banners, A/B variants, logged‑in state, and market/currency are all knowable server‑side.


Interaction‑triggered shifts are excluded — mostly

Shifts within 500 ms of a user interaction don't count toward CLS. Expanding an accordion or opening a filter drawer is fine.

But be careful:

// ⚠️ If the fetch takes longer than 500ms, the shift COUNTS
const onExpand = async () => {
  setExpanded(true);
  const data = await fetchDetails();    // 800ms
  setDetails(data);                     // shift at 800ms — outside the window 🔴
};

// ✅ Reserve the space when expanding, fill it when data arrives
const onExpand = async () => {
  setExpanded(true);                    // container animates to its final height immediately
  setDetails(await fetchDetails());     // content fills the reserved space
};

Sticky elements and mobile viewport

Two commerce‑specific gotchas:

Sticky headers that change height on scroll cause a shift the moment they collapse. Use transform to move them, not height changes.

/* ❌ Height change reflows everything below */
.header { height: 80px; transition: height 200ms; }
.header.compact { height: 56px; }

/* ✅ Keep the height; move/scale the contents */
.header { height: 80px; }
.header .logo { transition: transform 200ms; transform-origin: left center; }
.header.compact .logo { transform: scale(0.7); }

Mobile browser chrome resizing the viewport can trigger shifts with 100vh layouts. Use 100dvh/100svh where supported, and avoid pinning layout to viewport height on commerce pages.


Verification

// tests/cls.spec.ts — assert CLS stays under budget, with a slow network
import { test, expect } from '@playwright/test';

for (const path of ['/', '/c/womens-knitwear', '/p/wool-overshirt-navy', '/cart']) {
  test(`CLS under 0.05 on ${path}`, async ({ page, context }) => {
    const client = await context.newCDPSession(page);
    // Slow network so late-loading content actually loads late
    await client.send('Network.emulateNetworkConditions', {
      offline: false, downloadThroughput: 400_000, uploadThroughput: 200_000, latency: 400,
    });
    await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });

    await page.goto(path);
    await page.waitForTimeout(6000);         // let everything late arrive
    await page.evaluate(() => window.scrollTo(0, 400));   // trigger lazy content
    await page.waitForTimeout(2000);

    const cls = await page.evaluate(() => new Promise<number>((resolve) => {
      let total = 0;
      new PerformanceObserver((l) => {
        for (const e of l.getEntries() as any[]) if (!e.hadRecentInput) total += e.value;
      }).observe({ type: 'layout-shift', buffered: true });
      setTimeout(() => resolve(total), 500);
    }));

    expect(cls).toBeLessThan(0.05);
  });
}
// lighthouserc.js
'cumulative-layout-shift': ['error', { maxNumericValue: 0.05 }],
'unsized-images':          ['error', { minScore: 1 }],

Aurora's CLS fixes

Source Shift Fix Time
Promo bar inserted after paint 0.11 Server‑render from cookie 2 h
Font swap 0.06 next/font metric fallbacks 1 h
Product images without aspect ratio 0.04 aspect-ratio on containers 3 h
Review stars loading late 0.03 Reserved height + server render 2 h
Ad slot on PLP 0.02 min-height per breakpoint 1 h
Total 0.26 → 0.01 ~9 h

Nine hours for the full fix. This is why CLS goes first: it's the cheapest credibility you will ever buy.


Common mistakes

Mistake Consequence
Testing on fast wifi with a warm cache CLS looks like zero; users see 0.26
Skeleton dimensions ≠ content dimensions You built the shift on purpose
Anti‑flicker snippets for A/B tests Trades CLS for a much worse LCP
Inserting banners into the document flow The largest single shift on most sites
Reserving minimum rather than typical height Still shifts for the common case
Height‑animating sticky headers Shift on every scroll direction change
overflow-anchor: none Disables the browser's own scroll anchoring
Assuming shifts below the fold don't count They do, once that area is visible

Checklist

  • Every image has width/height or aspect-ratio
  • Fonts use metric‑compatible fallbacks
  • Banners overlay or are server‑rendered from a cookie
  • Ad slots have per‑breakpoint min-height
  • A/B variants decided server‑side/at the edge — no anti‑flicker snippet
  • Prices/stock server‑rendered or width‑reserved with tabular-nums
  • Late content (reviews, badges, personalization) has reserved space
  • Infinite scroll appends only, with a reserved loading row
  • Sticky headers animate with transform, not height
  • Playwright CLS test at 4× CPU on a slow network, in CI

Next: 6.4 TTFB playbook