Fix CLS Layout Shifts on Product Pages: Keep 'Add to Cart' Where It Belongs
Your Shopify store: product images compressed, WebP enabled, CDN running. You open PageSpeed Insights. Still yellow. You check CLS: 0.38 (failing).
The problem isn’t image size — it’s layout shift.
Your product page loads. The “Add to Cart” button appears. Customer moves their thumb to tap. Product image finishes loading — and the button jumps halfway down the page. That’s a lost sale.
This is the most frustrating part. You’ve done every optimization people told you about, but the metric won’t budge. That’s because you fixed file size, while CLS penalizes page jank.
How Common Is CLS? Let’s Look at the Data
In its 2023 Core Web Vitals report, Google identified CLS as one of the least-passed metrics on desktop1. According to HTTP Archive, fewer than 60% of all websites achieve “Good” CLS (< 0.1).
Here’s real-world CLS data from 5 common scenarios:
| Scenario | CLS Score | Root Cause |
|---|---|---|
| Banner image without width/height | 0.45 | Image pushes content down after loading |
| Third-party ad scripts | 0.32 | Dynamic DOM injection |
| Web font swap | 0.18 | Text reflow after font loads |
| Cookie consent banner injection | 0.22 | New element pushing everything downward |
| Embedded iframe | 0.28 | No preset dimensions |
Let’s fix them one by one.
01. Images Missing Width and Height — The Most Common Culprit
<!-- Problem: browser has no idea how much space to reserve -->
<img src="banner.webp" alt="Promo banner">
<!-- Fixed: browser reserves space ahead of time -->
<img src="banner.webp" alt="Promo banner" width="1200" height="600">
When you omit width and height, the browser allocates 0px height for the image during layout calculation. Then the image finishes loading and suddenly occupies 600px. Everything below gets shoved down. That’s your 0.45 CLS score right there.
The fix is two attributes: width and height. Add them and the browser pre-allocates space based on the aspect ratio. No displacement on load. Chrome 90+ automatically calculates aspect-ratio from these attributes, so even with CSS max-width: 100%, the reserved space is correct.
A CSS fallback is also good practice:
img {
aspect-ratio: attr(width) / attr(height);
max-width: 100%;
height: auto;
}
There’s one thing people overlook here: you need to know the actual dimensions of your images. If source images are inconsistent — some 2000px wide from one supplier, some 600px from another — even with width/height set, different sizes cause secondary layout variation across pages.
TinyOpt’s resize feature lets you batch-define a maximum width while preserving aspect ratio, producing uniformly-sized output. This way, the width and height you write into your templates are always correct — no distortion from mismatched ratios.
02. Third-Party Ad Scripts — You Can’t Control the Script, But You Can Control Its Container
Google AdSense loading flow: async request → ad content returns → dynamically create iframe → inject into DOM. Throughout this process, the ad script knows nothing about your layout, and you have no idea what size ad will be delivered.
Result: before the ad loads, the space is 0px tall. After it loads, a 280px ad card appears out of nowhere, pushing your product grid down an entire screen.
The fix isn’t controlling the ad script — it’s controlling its container.
<div class="ad-container">
<!-- Ad script injection point -->
<ins class="adsbygoogle"
data-ad-client="xxx"
data-ad-slot="xxx"></ins>
</div>
.ad-container {
min-height: 280px; /* Pre-allocate sufficient height */
width: 100%;
background: #f9f9f9;
}
The key is min-height. Even if the ad fails to load and returns blank, the container height stays fixed. Content below doesn’t move.
This same principle applies to recommendation widgets, social plugins, and chat widgets. Any third-party script that injects content after page load belongs inside a container with predefined min-height.
03. Web Fonts Causing Text Jumps — font-display Is Critical
You visit a site. Text renders in Arial. Half a second later, it swaps to a custom font. If the two fonts have different character widths, line breaks reposition. That’s CLS.
The fix has two parts.
One: Set font-display:
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* or optional */
}
swap tells the browser: render with the fallback font first, then replace when the custom font loads. Paired with the next setting, it dramatically reduces CLS.
Two: Use size-adjust to match fallback and custom font widths:
@font-face {
font-family: 'CustomFont-fallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 90%;
}
With this, Arial-rendered text matches CustomFont’s width almost exactly before the font swap — minimal visible shift2.
👉 Download TinyOpt and standardize your image dimensions first
04. Dynamic Content Injection — Cookie Banners, Notification Bars, Promo Strips
Typical cookie banner implementation: page loads → JS checks cookie → not found → injects an element at the top of <body>. That one insertion pushes all page content down by the banner’s height. CLS +0.22.
The fix follows the same principle: pre-allocate space.
If the banner can be fixed-positioned:
#cookie-banner {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 1000;
}
position: fixed removes the element from the document flow entirely — inserting it into the DOM pushes nothing.
If fixed positioning isn’t an option (e.g. the banner must occupy document flow), use a placeholder:
<div id="cookie-banner-container" style="min-height: 60px;">
<!-- JS dynamically fills this container -->
</div>
05. Embeds Without Preset Dimensions — YouTube, Maps, Twitter Cards
Embedding a YouTube video without styling:
<!-- Problem: only after loading do you discover it's a 560×315 player -->
<iframe src="https://www.youtube.com/embed/xxx"></iframe>
Correct approach:
<iframe
src="https://www.youtube.com/embed/xxx"
width="560"
height="315"
style="max-width: 100%; height: auto;">
</iframe>
Or use CSS aspect-ratio for responsive behavior:
.video-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
}
.video-wrapper iframe {
position: absolute;
width: 100%;
height: 100%;
}
The 3-Step CLS Fix Process
Don’t fix everything at once. Follow this sequence and validate each step.
Step 1: Locate the shifting elements
Chrome DevTools → Performance panel → record page load → check the “Experience” row. Red rectangles mark layout shifts. Click a red block, and the Summary panel shows the shift score and which DOM element moved.
You can also run Lighthouse’s “Avoid large layout shifts” audit — it lists the DOM elements causing the largest shifts directly.
Step 2: Fix one at a time, verify each fix
Fix one problem → re-record Performance → confirm the shift is gone → move to the next. Fixing too many at once makes it impossible to know which change worked.
Target: CLS below 0.1. For most content sites, fixing image dimensions, ad containers, and fonts drops CLS from 0.38 to 0.05 in about 30 minutes.
Step 3: Add Lighthouse CI to your pipeline
Fixing is not the end. Your next release might introduce new CLS from a new JS component. Integrate Lighthouse CI into GitHub Actions or Jenkins:
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v12
with:
urls: |
https://your-site.com/
budgetPath: .github/lighthouse/budget.json
Set a CLS threshold of 0.1 and block merges when exceeded. You’ll never wake up to a CLS regression again.
👉 Download TinyOpt — get your image dimensions right in one pass
Pattern Summary: All CLS Shares One Root Cause
Look back at those 5 scenarios. They’re not 5 separate problems — they’re one problem wearing 5 different masks.
Root cause: the browser doesn’t know how much space to reserve before content loads.
- Images without dimensions → browser can’t calculate reserved space → expands after load.
- Ads without containers → browser doesn’t know the ad height → displaces content after load.
- Mismatched fonts → browser doesn’t know the fallback width → reflow after swap.
- Cookie banners → browser doesn’t know an element is coming → push after insertion.
- Iframes without dimensions → browser doesn’t know the embedded content size → reveals after load.
The fix is always the same: tell the browser in advance how much space to reserve.
CLS isn’t some deep optimization technique. It’s an information gap problem — you know what the page will look like, but the browser doesn’t. Your job is to pass that information along ahead of time.
FAQ
01. I added width and height to my images, but now they look stretched?
Your CSS likely has a height: 100% declaration or a fixed pixel height that overrides the natural aspect ratio. Find and remove those global styles. Replace with height: auto paired with max-width: 100%. Alternatively, use the aspect-ratio property to explicitly declare the ratio.
02. How much SEO improvement can I expect from reducing CLS from 0.4 to 0.1?
Not a direct ranking boost, but significant indirect impact. CLS is one of the three Core Web Vitals, which directly influence Google’s page experience ranking signal. Moving from 0.4 (“Needs Improvement”) to 0.1 (“Good”) turns a red penalty into a green pass. Some data shows sites with all Core Web Vitals passing saw an average 8% increase in mobile organic traffic3.
03. Does lazy loading (loading=“lazy”) introduce CLS?
Yes, and it’s very common. Lazy-loaded images without width and height start loading only near the viewport, then expand the page once loaded — effectively delaying the CLS from “on page open” to “during scroll.” The fix is identical to regular images: add width and height attributes.
04. How do I permanently fix CLS from dynamic content like live notifications or chat popups?
Three approaches: ① position: fixed to remove the element from document flow; ② Insert an empty placeholder container with min-height matching the expected height; ③ Use CSS transform/opacity for appearance animations rather than direct DOM insertion. These can be combined.
05. I have hundreds of legacy images without width/height. How do I batch-fix them?
Three methods:
- Server-side: Use a script (Python PIL, Node sharp) to batch-read image dimensions and auto-generate
<img>tags with width/height. - Template level: If using an SSG (Astro, Next.js), read image metadata at build time and inject into tags automatically.
- Pre-process images: Use TinyOpt to batch-resize and compress all images to consistent dimensions, then write a single width/height value in your template — because all output images have known, uniform sizes.
👉 Download TinyOpt, batch-process your images today
Three Things You Can Do Today
One: Open Chrome DevTools Performance panel and see what your actual CLS score is.
Two: Search your entire codebase for <img and count how many tags lack width/height attributes. Add them globally.
Three: Add min-height to every container wrapping a third-party script.
Do these three, and CLS will likely go from red to green.
Footnotes
-
Google. “The state of Core Web Vitals.” web.dev, February 2023. https://web.dev/top-cwv-2023/ ↩
-
Google Chrome Developers. “Optimize Cumulative Layout Shift.” web.dev, 2024. https://web.dev/optimize-cls/ ↩
-
Sistrix. “Core Web Vitals as a ranking factor — data study.” sistrix.com, July 2021. https://www.sistrix.com/blog/core-web-vitals-ranking-factor-data/ ↩
TinyOpt Team
The team behind the TinyOpt open-source project, focused on building and optimizing desktop image processing tools. We help designers, front-end developers, and content creators process images in bulk — efficiently and for free.