Key takeaways
- CLS on Shopify measures unexpected layout shift. Each shift scores impact fraction multiplied by distance fraction, and your CLS is the worst burst of shifts on the page, not the total.
- Google’s thresholds are 0.1 or less for good and above 0.25 for poor, measured at the 75th percentile of real page loads.
- Lab CLS often looks fine while CrUX says poor, because a lab run only captures shifts during its own page load at one fixed viewport.
- Most Shopify CLS traces to a short list: images without dimensions, web fonts, announcement bars, sticky headers, and app-injected widgets above the fold.
- CrUX is a 28-day rolling average, so a shipped fix takes up to four weeks to show fully in PageSpeed Insights field data.
CLS on Shopify is the metric that punishes you for content that moves after the page starts rendering. The shopper reaches for the Add to cart button, a review widget loads above it, and the tap lands on something else.
This guide covers how the score is actually computed, how to find the shifting element instead of guessing, and the specific Shopify causes worth checking first. The diagnosis half applies to any site. The fixes are theme-level and Liquid-level.
Why you can trust us
We have been in the Shopify space for over four years and have worked with hundreds of Shopify brands on their storefronts. Jacques has over 15 years of development experience. We build Fudge, an AI storefront editor with a 5.0 rating on the Shopify App Store and Built for Shopify status, so we work on the same theme layer where layout shift starts.
What does CLS measure and how is the score calculated?
CLS measures the largest burst of unexpected layout shift that occurs across a page’s entire lifecycle.1
Two words in that definition matter.
“Unexpected.” A shift that happens within 500 milliseconds of a discrete user input, such as a tap, click, or keypress, gets the hadRecentInput flag and is excluded.1 Scrolls, drags, and pinch-zoom do not count as recent input, so a shift during scroll still scores.
“Largest burst.” CLS is not the sum of every shift on the page. Shifts are grouped into session windows: a burst of shifts each less than one second apart, with a maximum total window of five seconds.1 Your CLS is the highest-scoring window.
Impact fraction and distance fraction
Each individual shift scores:
layout shift score = impact fraction x distance fraction
Impact fraction is the combined visible area of every unstable element across the current and previous frame, as a fraction of the viewport area.1
Distance fraction is the greatest distance any unstable element moved in that frame, divided by the viewport’s largest dimension.1
The practical consequence: a small element moving a long way and a large element moving a short way can score the same. A full-width hero pushed down 100px on mobile is expensive because the impact fraction is close to 1.
What is a good CLS score in 2026?
| Score | Rating |
|---|---|
| 0.1 or less | Good |
| Above 0.1 up to 0.25 | Needs improvement |
| Above 0.25 | Poor |
Thresholds are assessed at the 75th percentile of page loads, segmented separately for mobile and desktop.1 Your median visitor can have a clean experience while you still fail, because the slowest quarter of loads sets the grade.
Google states that Core Web Vitals align with what its core ranking systems reward, and names 0.1 as the CLS target.2 It is one input among many, not a switch. For the wider picture of where Shopify stores currently sit, see our state of Shopify performance report.
Why does lab CLS look fine when CrUX says poor?
This is the most common confusion, and it is not a bug in either tool.
Lab data is a single synthetic load: one emulated device, one viewport, a cold cache, no scrolling, no interaction. Lighthouse reports the shift it sees during that trace and then stops.
Field data is CrUX, aggregated from real Chrome users across the whole page lifecycle. It includes the shift that happens when someone scrolls to the reviews, switches a variant, or waits on a slow connection while an app widget resolves.
Four gaps produce the mismatch:
- Timing. On a fast lab connection a late script lands before first paint and shifts nothing. On real 4G it lands after.
- Interaction. Lab runs never scroll, so a collection page that jumps on lazy-load looks perfect.
- Viewport spread. A banner that wraps to two lines at 360px shifts everything below it.
- Geography. Currency converters and geolocated banners only fire for some visitors.
If lab says 0 and field says 0.3, trust the field data and go looking for interaction and scroll shifts.
How to diagnose CLS on a Shopify store
Work field-first, then reproduce locally.
1. Read the field data. Run the live URL through PageSpeed Insights and read the CrUX section at the top, not the Lighthouse score below it. Check mobile and desktop, and check a product page, a collection page, and the homepage separately. Search Console’s Core Web Vitals report groups similar URLs, which tells you whether the problem is one template or the whole theme.
2. Record a trace. In the Chrome DevTools Performance panel, throttle to a slow connection and 4x CPU, then record a reload. The Layout Shifts track fills with purple bars. Click an individual shift to see the element that moved and a before and after screenshot. The Layout shift culprits insight highlights the worst cluster and offers a best guess at the cause.
3. Turn on the overlay. In DevTools, open Settings, then More tools, then Rendering, and tick Layout Shift Regions. Shifting areas flash purple as they move.3 This is the fastest way to catch shifts you would otherwise blink past.
4. Chase the interaction shifts. Lab runs will not find these, so drive them by hand with the Performance panel’s live metrics view open: scroll the full page, switch product variants, open the cart drawer, change currency, apply a filter on a collection page. Watch the CLS number climb and note what you were doing.
5. Instrument the field. For shifts you cannot reproduce, log real ones. The Layout Instability API gives you the offending nodes in entry.sources:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log(
entry.value,
entry.sources.map((s) => s.node),
)
}
}
}).observe({type: 'layout-shift', buffered: true})
Each source carries previousRect and currentRect, so you can see exactly how far a node moved. Note that the element that moved is usually the victim, not the culprit. The culprit is whatever appeared above it.
Shopify-specific CLS causes and their fixes
| Cause | Fix |
|---|---|
| Images or video with no dimensions | width and height attributes, or aspect-ratio on the wrapper |
| Web font swap | font-display, preload the font file, metric-matched fallback |
| Announcement or free-shipping bar | Render in Liquid server-side, or reserve min-height |
| Cookie banner | Fixed overlay outside document flow |
| App widgets (reviews, currency, upsell) | App blocks plus a reserved min-height container |
| Lazy-loaded above-the-fold content | loading="eager" and fetchpriority="high" on the hero |
| Sticky header | position: sticky, or a spacer matching header height |
| Variant switching | Fixed aspect-ratio on the product media wrapper |
| Infinite scroll | Skeleton placeholders, or a Load more button |
| Late-arriving section CSS | Load above-the-fold CSS synchronously |
Images and video without dimensions
Set width and height so the browser reserves the box before the file arrives, then let CSS handle responsiveness:
<img src="hero.jpg" width="1600" height="900" alt="Autumn collection" />
img {
width: 100%;
height: auto;
}
In Liquid, the image_tag filter emits width and height for you, calculated from the source image:
{{ product.featured_image | image_tag: widths: '400, 800, 1200', sizes: '(min-width: 750px) 50vw, 100vw' }}
For video, embeds, and anything with no intrinsic size, reserve the box on the wrapper:
.video-wrapper {
aspect-ratio: 16 / 9;
}
Web fonts and the swap shift
A custom font loads, replaces the fallback, and every line of text re-measures. If the two fonts have different metrics, the block changes height and pushes content down.
Three fixes, in order of effort:
Set font-display. Shopify’s font_face filter takes the parameter directly:
{{ settings.type_body_font | font_face: font_display: 'swap' }}
swap shows the fallback immediately then swaps, so text is always visible but the shift still happens. optional avoids the shift entirely by declining to swap if the font is slow, at the cost of some visitors never seeing your brand font.
Preload the file so the swap happens before first paint rather than after:
<link rel="preload" href="{{ settings.type_body_font | font_url }}" as="font" type="font/woff2" crossorigin>
Match the fallback metrics. This removes the shift without giving up the font. Declare a local fallback with overrides tuned to your web font, then list it in the stack:
@font-face {
font-family: 'Body Fallback';
src: local('Arial');
size-adjust: 103%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: 'Your Font', 'Body Fallback', sans-serif;
}
The percentages are specific to each font pair. Measure them rather than copying these.
Announcement bars, cookie banners, and shipping bars
Anything injected above the fold pushes the entire page down, which means an impact fraction near 1.
Render it in Liquid. A bar that ships in the server-rendered HTML never shifts anything, because it was there from the first frame. This is the fix in almost every case.
If it has to be JavaScript, reserve the height before it renders:
.announcement-bar {
min-height: 40px;
}
Set the same value on the placeholder for every breakpoint where the text wraps to a second line. A 40px reservation for a bar that renders at 72px on a 360px screen is still a shift.
Cookie banners belong outside the flow. Use position: fixed so the banner overlays content rather than displacing it.
App-injected content
Review widgets, currency converters, upsell blocks, size charts, and chat bubbles all load late and expand into the layout.
Prefer app blocks added through the theme editor. They render inside the section, in the document flow, at server render time. Legacy widgets injected by script arrive after paint and shift whatever sits below them.
Where the app still injects late, reserve the container:
.shopify-app-block {
min-height: 24px;
}
.product-reviews-placeholder {
min-height: 180px;
}
Chat bubbles should be position: fixed, never in flow. For a walkthrough of isolating which app is responsible, see our guide on fixing layout issues after installing Shopify apps.
Lazy-loaded content above the fold
loading="lazy" on a hero image is a self-inflicted shift. The browser defers the fetch, the space stays empty during the critical window, and content settles late.
Give the first viewport eager loading and a high fetch priority:
<img src="hero.jpg" width="1600" height="900" loading="eager" fetchpriority="high" alt="Hero" />
Full detail is in our guide on the Shopify hero banner eager loading fix.
The same applies to sections revealed by JavaScript. Fade-in animations that animate height, top, or margin register as layout shifts on every frame. Animate transform and opacity instead, which the compositor handles without reflow.
Sticky headers
A header that switches to position: fixed on scroll leaves the document flow, and the page below collapses upward by the header’s height.
Use position: sticky instead, which keeps the element’s space reserved. If the design needs fixed, insert a spacer element of matching height at the moment the class flips, and keep the two in sync with a CSS custom property.
Product page variant switching
Two shift sources here.
Different image dimensions. A portrait variant image replacing a square one changes the media block height. Lock the wrapper:
.product-media {
aspect-ratio: 1 / 1;
}
.product-media img {
width: 100%;
height: 100%;
object-fit: contain;
}
Section Rendering API swaps. Re-rendering the product form on variant change replaces markup with markup of a different height, usually because a sale badge, an inventory notice, or a subscription selector appears for one variant and not another. Reserve a min-height on the region that gets replaced.
These shifts land within 500ms of a click, so they are often excluded from CLS. They still hurt usability, and any knock-on reflow that arrives later is not excluded.
Infinite scroll on collection pages
Appending products moves the footer and anything else in view. Reserve the space with skeleton placeholders sized to the real grid rows, or use a Load more button, which makes the shift user-initiated.
For long grids, size containment helps the browser guess correctly for off-screen rows:
.product-grid__item {
content-visibility: auto;
contain-intrinsic-size: auto 420px;
}
Late-arriving CSS
Section stylesheets loaded asynchronously restyle content that has already painted. Load the CSS for above-the-fold sections normally and defer only what sits below. Our guide on render-blocking scripts in Shopify covers where the line sits.
Where Fudge fits
Most of the fixes above are small, specific edits to Liquid, CSS, and theme JavaScript. Fudge writes those changes as native theme code rather than rendering a layer on top of your store.
Ask for a reserved height on the announcement bar or a locked aspect ratio on the product media wrapper, and the change lands in the theme files. No extra script, no widget, which matters when the metric you are chasing is caused by extra scripts and widgets. The same applies to anything else built with the Shopify store editor: remove Fudge and the code stays.
How to verify the fix and when CrUX catches up
Verification runs in three stages, and the last one is slow.
Immediately: re-record. Trace the page again in the Performance panel with the same throttling. The Layout Shifts track should be empty or close to it. Repeat with Layout Shift Regions on, scrolling the full page and switching variants.
Within a day: check lab. Run PageSpeed Insights against the live URL. Lab CLS confirms the load-time shifts are gone and says nothing about interaction shifts, so do not stop here.
Over 28 days: watch the field. CrUX is a 28-day rolling average, updated daily around 04:00 UTC.4 The day after you ship, 27 of the 28 days still contain the broken page. Improvement appears gradually and only reaches its true value about four weeks after the fix is live for all traffic.
Two things trip people up here. The fix must be published to the live theme, since CrUX only observes real visitors. And a URL needs enough traffic to be reported at all, so quiet pages may only ever show origin-level data.
Once CLS is settled, the same loop applies to the rest of the vitals. Our guide on how to speed up a Shopify theme covers the load-time side.
FAQ
0.1 or less is good, above 0.1 up to 0.25 needs improvement, and above 0.25 is poor. Google assesses this at the 75th percentile of real page loads, segmented separately for mobile and desktop. That means a quarter of your visitors can exceed the threshold and still fail you, so mobile is usually the number to chase.
A lab run is one synthetic load on one emulated device with no scrolling and no interaction, so it only sees shifts that happen during that trace. CrUX field data comes from real Chrome users across the entire page lifecycle, including shifts triggered by scrolling, variant switching, and app scripts that arrive late on slow connections. When the two disagree, trust the field data and go looking for interaction and scroll shifts.
Frequently. Review widgets, currency converters, upsell blocks, and chat bubbles inject content after the page has painted, pushing whatever sits below them down. App blocks added through the theme editor are safer because they render in the document flow at server render time. For script-injected widgets, reserve the space with a min-height on the container.
Record a trace in the Chrome DevTools Performance panel with throttling on, then click individual shifts in the Layout Shifts track to see the element and a before and after screenshot. Turning on Layout Shift Regions in the Rendering tab flashes shifting areas purple in real time. Remember that the element that moved is usually the victim, and the culprit is whatever appeared above it.
CrUX is a 28-day rolling average updated daily, so improvement appears gradually rather than overnight. Expect roughly four weeks after the fix is live on the published theme before the field score reflects its true value. Lab tools confirm the fix immediately, but they only cover load-time shifts.
It does when it is injected by JavaScript rather than rendered in Liquid, because it appears above everything else and pushes the whole page down. Rendering the bar server-side in the theme removes the shift entirely. If it must be dynamic, set a min-height on its container that matches the rendered height at every breakpoint, including the ones where the text wraps to two lines.
Google states that Core Web Vitals align with what its core ranking systems reward, and names a CLS under 0.1 as the target. It is one signal among many rather than a decisive one. The stronger commercial argument is usually usability: a page that moves under the shopper's thumb produces mis-taps on the Add to cart button.
Footnotes
-
web.dev, “Cumulative Layout Shift (CLS)” - definition, the impact fraction times distance fraction formula, the one-second gap and five-second maximum session window, the 500ms
hadRecentInputexclusion, and the 0.1 / 0.25 thresholds at the 75th percentile. https://web.dev/articles/cls ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Google Search Central, “Understanding Core Web Vitals and Google search results” - states that page experience aligns with what core ranking systems reward, and gives 0.1 as the CLS target. https://developers.google.com/search/docs/appearance/core-web-vitals ↩
-
Chrome for Developers, “Discover issues with rendering performance” - the Rendering tab’s Layout Shift Regions option briefly highlights shifting areas in purple. https://developer.chrome.com/docs/devtools/rendering/performance ↩
-
Chrome for Developers, “CrUX API” - the Chrome UX Report is a 28-day rolling average of aggregated metrics, updated daily around 04:00 UTC. https://developer.chrome.com/docs/crux/api ↩