How to Fix CLS on a Shopify Theme (Cumulative Layout Shift)
CLS bugs on Shopify almost always trace to one of 5 culprits. Here's the diagnostic checklist.
Cumulative Layout Shift measures unexpected movement of visible page content. A CLS score under 0.1 is Good. Above 0.25 is Poor. On Shopify, most CLS failures trace to five specific patterns — all of them fixable in theme.liquid or a section template without rebuilding your store.
Here's the diagnostic checklist, ordered by how commonly each culprit appears.
How to Identify the Shifting Elements
Before fixing anything, find out what's shifting and when.
Open Chrome DevTools, go to the Performance panel, record a page load, and look for Layout Shift events in the Experience row. Click each one to see which elements shifted, by how much, and what triggered the shift.
The Web Vitals Chrome extension shows a real-time CLS score and highlights shifting elements in a red overlay as they move. This is the fastest way to visually identify the problem.
PageSpeed Insights shows CLS in both Lab and Field data. The Field data (from real Chrome users via CrUX) is what affects your ranking signal. If Lab CLS is low but Field CLS is high, you have a shift that only happens under specific conditions — slow connections, particular user agents, or specific interaction sequences.
Culprit 1: Announcement Bar Injected After Load
Shopify themes commonly show announcement bars (sale alerts, shipping thresholds, cookie notices) that are injected into the DOM after initial render. The bar pushes the header and everything below it down, causing a large CLS spike on almost every page load.
The fix: Reserve space for the bar in CSS before it renders, even if the content is empty:
/* In your theme's base.css or inline styles */
.announcement-bar {
min-height: 48px; /* Match the bar's actual height */
display: flex;
align-items: center;
justify-content: center;
}
If the bar is conditionally shown (e.g., only when a sale is active), set min-height: 0 as the default and use a Liquid variable to set the reserved height:
{%- if settings.show_announcement -%}
<style>
.announcement-bar { min-height: 48px; }
</style>
{%- endif -%}
<div class="announcement-bar">
{{ settings.announcement_text }}
</div>
This way, space is reserved before the JS runs, so nothing shifts when the bar content loads.
Culprit 2: Image Carousels Without Aspect Ratio
Product image carousels that load without declared dimensions shift the layout while images download. The browser doesn't know how tall the image is until it downloads — so it renders the carousel at zero height, then shifts everything down when the image appears.
The fix: Declare aspect-ratio on the image container or use width and height attributes on the <img> tag:
{%- assign image = product.featured_image -%}
<div class="product-image-wrapper" style="aspect-ratio: {{ image.width }} / {{ image.height }};">
<img
src="{{ image | image_url: width: 800 }}"
alt="{{ image.alt | escape }}"
width="{{ image.width }}"
height="{{ image.height }}"
loading="eager"
/>
</div>
The width and height attributes let the browser calculate the aspect ratio before the image loads, reserving the correct amount of space. aspect-ratio on the wrapper does the same for containers where you need CSS-controlled sizing.
Culprit 3: Font Swap Without font-display: optional
Custom fonts that load after the initial render cause text to reflow when the font is applied — a shift that's sometimes large enough to affect CLS scoring. The default browser behavior is to hide text until the font loads (FOIT), then show it. This doesn't cause CLS. But font-display: swap — which shows fallback font text immediately — does cause CLS when the custom font loads and the text layout changes.
The options:
font-display: optional — shows the font only if it loads within a very short window (roughly 100ms). If it doesn't, uses the fallback permanently. This is the safest CLS option and the one Google recommends for CWV.
font-display: swap — shows fallback immediately, swaps on load. Better for perceived performance but worse for CLS.
In your Shopify theme's CSS:
@font-face {
font-family: 'YourFont';
src: url('{{ "yourfont.woff2" | asset_url }}') format('woff2');
font-display: optional; /* Use fallback if font doesn't load fast enough */
font-weight: 400;
font-style: normal;
}
Also preload the font in theme.liquid:
<link
rel="preload"
href="{{ "yourfont.woff2" | asset_url }}"
as="font"
type="font/woff2"
crossorigin
/>
With preload + font-display: optional, the font loads fast enough in most cases to be used without a swap, eliminating the CLS.
Culprit 4: Lazy-Loaded Reviews Widget
Third-party reviews widgets (Judge.me, Yotpo, Okendo) are common on Shopify product pages. They load asynchronously and inject content into the page after the initial render. If they load after the user sees the page, everything below the widget shifts down.
The fix: Reserve space for the widget container. Look at the widget's rendered height in DevTools, then set a min-height on the container:
<div class="reviews-container" style="min-height: 320px;">
{% render 'judgeme_widgets', widget_type: 'judgeme_review_widget', concierge_install: true, product: product %}
</div>
320px is approximate — measure your actual widget height in DevTools and use that value. If you're on a product page where the reviews section is below the fold, you can also use an IntersectionObserver to load the widget only when it's near the viewport, so it never shifts visible content.
Culprit 5: Sticky Header Transitions
Sticky headers that animate into view (sliding in from the top, fading in after scroll) cause CLS if they push content down when they appear. This is especially common with "scroll to show sticky header" patterns.
The fix: Use position: fixed instead of position: sticky combined with a CSS transform for the animation. A fixed-position element is removed from the document flow — it can't cause layout shift.
.sticky-header {
position: fixed;
top: 0;
left: 0;
right: 0;
transform: translateY(-100%);
transition: transform 0.2s ease;
z-index: 100;
}
.sticky-header.is-visible {
transform: translateY(0);
}
window.addEventListener('scroll', () => {
const header = document.querySelector('.sticky-header');
if (window.scrollY > 100) {
header.classList.add('is-visible');
} else {
header.classList.remove('is-visible');
}
});
The transform animation doesn't trigger layout — it uses GPU compositing. The header slides in without shifting any content below it, so CLS is unaffected.
Testing CLS in PSI Mobile Mode
CLS scores often differ significantly between desktop and mobile. Run PageSpeed Insights in Mobile mode for every test — that's the mode Google's crawler uses for indexing and ranking signals. Mobile users have slower connections and smaller viewports, which makes timing-dependent shifts more likely.
After fixing each culprit, re-run PSI and check both the Lab CLS score (updates immediately) and the Field CLS score (takes 28 days to reflect changes in CrUX data).
For the full Core Web Vitals overview including LCP and INP thresholds, see the Core Web Vitals guide. If you're also seeing LCP issues on your Shopify store, the underlying causes are usually TTFB (your theme's Liquid rendering time) and unoptimized images — both separate from the CLS fixes above. RankCrab's audit runs PageSpeed Insights on your Shopify URLs and surfaces LCP, INP, and CLS scores in one place, so you can track all three metrics as you work through fixes.