← Insights

Shopify cumulative layout shift: what moves on a product page, and how to hold it under 0.1

The gallery image, app widgets, a scripted banner and the font swap: what each moves on a product page, what it costs against 0.1, and a five-minute check.

13 min read

Contents — 6 sections

The score is the largest burst of movement, not the sum of it

A layout shift is a visible element changing its start position between one rendered frame and the next. Cumulative layout shift is the page's score for those movements, and Google's definition on web.dev has three parts that decide what you can get away with.

The first is the arithmetic. Each shift scores as impact fraction times distance fraction: the share of the viewport the moved elements occupied before and after, multiplied by the largest distance any moved as a share of the viewport's longest side. Web.dev's example is an element covering half the viewport moving down by a quarter of it: 0.75 times 0.25, which is 0.1875. One such shift fails the page on its own.

The second is the grouping. Shifts less than a second apart are gathered into a session window of at most five seconds, and the page's CLS is the highest-scoring window, not the total across the visit. A page that moves at load and again half a second later, when a review widget arrives, has one window with both shifts added together. Four seconds apart, they are scored separately and the page keeps the larger.

The third is what does not count. A new element is not a shift unless it pushes an existing one, and neither is an element changing size unless something else moves. Shifts within 500 milliseconds of a tap, click or keypress carry a hadRecentInput flag and are excluded; a scroll is not input for this purpose. And an element positioned fixed over the page moves nothing beneath it, which is why an overlay is free and an in-flow banner is not.

The threshold is 0.1 or less at the 75th percentile of page loads, mobile and desktop measured separately, per web.dev; above 0.25 is poor. Google Search Central states the same figure and says good Core Web Vitals align with what its ranking systems reward. On a Shopify product page the things that move are the same four every time: the gallery image, the widgets apps place under the title, a banner that arrives by script, and the text when the web font lands. Each has a mechanism, a place in the theme where it is decided, and a fix with a known cost.

An image with no reserved height starts at zero pixels tall

Shopify's layout shift note lists the six steps in order: the browser parses the HTML and meets an img, assumes its height is zero because it knows nothing else, renders the page with no space reserved, downloads the file, discovers the real dimensions, and pushes everything below the image down to fit it. On a product page that is the title, the price, the variant picker and the add-to-cart button, moving together by the full height of the gallery in the first seconds of the visit: the 0.1875 example, on the one page that has to sell.

The fix is two attributes and two lines of CSS. width and height on the img, from which the browser derives an aspect ratio and reserves the box before the first byte of image arrives; web.dev's optimisation guide shows the rule Chrome writes for itself, aspect-ratio: auto 640 / 360. Then max-width: 100% and height: auto in the stylesheet, so the reserved box scales with the column. A theme that sets height: 100% on the image, or a fixed height on the container, has thrown the ratio away.

On Shopify the attributes come from the image_tag filter; its reference says width and height are added by default from the image the URL points at. Dawn, Shopify's reference theme, goes further on the product page: the product thumbnail snippet writes --ratio from media.aspect_ratio onto the media container's style attribute, and the section's CSS turns that into a padding-top of one over the ratio, so the gallery's height is settled before the image request is made.

Where it goes wrong is the code that is not Dawn's: a hand-written <img src="..."> in a custom section, or an app widget rendering its own images, carries no attributes and no ratio. And where the images disagree with each other: web.dev's guide says every candidate in a srcset must share one aspect ratio, and a gallery with a 1:1 first image and a 4:5 second changes the box's height on every swap. A swap the buyer tapped for is excluded for 500 milliseconds; one that needed a network fetch and took longer is not. The first gallery image is also the LCP element on most product pages, and the loading half of the same problem is in Shopify hero image size.

Content that arrives by script pushes everything below it down

Shopify's page on app-injected content describes the sequence: the browser renders the theme's HTML, the app's JavaScript loads and runs, the script inserts new content, and the browser reflows to make room. The widgets it names are the ones every product page has: a star rating under the title, a row of trust badges or payment icons, a shipping estimate, a stock indicator. Each arrives after first paint and moves the price and the button down by its own height.

There are two ways an app gets onto the page, and the fix depends on which. Apps built on theme app extensions add content as app blocks, placed inside a section in the theme editor, or as app embed blocks, which the extension configuration reference says Shopify injects before the closing head and body tags and are meant for floating or overlaid components such as chat bubbles. Older apps inject by script tag wherever they choose.

For an app block the space can be reserved, because the block has an address. Shopify wraps every block in an element carrying shopify-block, adds shopify-app-block to the ones from apps, and gives each an id of shopify-block- followed by the block's ID. Select the parent section in the theme editor, open its Custom CSS, and set a min-height on the wrapper so the box exists before the widget fills it:

/* Custom CSS on the product information section */
.product__info-container .shopify-app-block {
  min-height: 28px;
}

@media screen and (max-width: 749px) {
  .product__info-container .shopify-app-block {
    min-height: 56px;
  }
}

The value comes from the widget: inspect it in DevTools, read its container's offsetHeight, and use that or slightly more, on mobile as well because widgets wrap. Shopify's rough bands are 24 to 30 pixels for a star row, 48 to 60 for a badge row or review summary, 80 to 200 for a full review widget. Scope the selector to the section that holds the block; a min-height on every app block reserves space where nothing will arrive.

For an app embed or a script-tag app that inserts itself into the flow, there is no wrapper to target. Shopify's guidance is that these shift layout only when they render in normal flow, so the fix is position: fixed; web.dev's cookie notice guidance says the same of top-of-screen banners, reserve the space or make it a sticky footer or a modal. The distinction matters most for the promotional bar. Dawn's announcement bar is a Liquid section, in the HTML from the first byte, and it moves nothing. An app that draws the same bar by script at the top of the body moves the entire page by the bar's height, where web.dev's guide says the impact is greatest. Shopify's performance team, in its CLS article, shows the only theme-side answer for a script-tag widget, a sibling selector adding top margin where it will land, and calls it a hack. The better answers are below the fold, out of the flow, or a different app.

The same article names two shifts that come with themes rather than images. A section stylesheet loaded with an async pattern arrives after first paint and restyles what is already on screen; load CSS for sections above the fold synchronously and keep the pattern for section.index further down. And a banner that slides in by animating bottom or top registers a shift on every frame even though nothing visible is displaced; Shopify's animation rule is transform: translateY() instead. A sticky add-to-cart bar is the same case, and that post covers the padding it needs.

The font swap moves every line of text at once

Shopify's font_face filter writes the @font-face rule for a font chosen in theme settings, and its reference shows it adds no font-display unless one is passed. Shopify's font swapping note spells out the consequence: the browser falls back to auto, which in Chrome behaves like block, and text is invisible for up to three seconds. With font_display: 'swap' the text paints at once in a system fallback and the web font replaces it, typically 100 to 500 milliseconds after first paint. If the two fonts differ in character width or line height, every line reflows in the same frame, and Shopify puts the cost on a text-heavy page at 0.05 to 0.15. Both routes shift: web.dev's font guidance notes that invisible text is still laid out in the fallback's metrics.

Dawn's layout file does what the rules ask: font_face: font_display: 'swap' on all five faces it declares, and a preload of the body and heading WOFF2 files so they can arrive before first paint, in which case there is no swap to see. A theme that does not is one line per face in theme.liquid.

The swap itself is a metrics problem, and Shopify's note orders the fixes. First, a unitless line-height on the body, so every line's height is a multiple of the font size rather than of the font's own ascent and descent; that is the vertical half in one declaration. The horizontal half is glyph width, which changes where lines wrap. That takes a second @font-face for the fallback, built from local() and adjusted until it occupies the same space as the web font:

@font-face {
  font-family: "Brand Font Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: "Brand Font", "Brand Font Fallback", sans-serif;
}

The percentages are per font pair and have to be measured, not copied; Shopify's note gives the ratios and the tools that compute them from the font files, and web.dev's size-adjust article is the reference for the descriptors. A smaller font file shortens the window before the swap but not the size of the shift.

Two ways out altogether, if the brand allows them. font-display: optional gives the font 100 milliseconds and otherwise uses the fallback for the whole visit; web.dev's table shows it as the one value with no swap period, at the price of the web font sometimes not appearing. And Shopify's system font page covers the other: a font_picker default of system_ui_n4 means no request, no swap and no font shift, because there is no file. A licensed font outside Shopify's library follows the self-hosting guide: WOFF2 in assets, @font-face inline in the layout, font-display: swap.

Measure it on the product page, in the field before the lab

Run PageSpeed Insights on the best-selling product page, mobile first. The top of the report is field data from the Chrome User Experience Report, which is what Search Console reports and the number that matters; Shopify's lab and field guide notes it is the 75th percentile over a 28-day rolling window, that a low-traffic page may have no data of its own, and that a fix takes weeks to show. Below that is the lab run, and web.dev's caution applies: a lab tool sees only the shifts that happen during load, so a widget that arrives late, a variant swap or a slide-in banner can be invisible to it and present in the field.

Then find the element. Open the page in Chrome DevTools, and in the Network panel disable the cache and set throttling to a slow profile, because a shift that resolves in a hundred milliseconds on a fast connection is easy to miss. Open the Performance panel and record a reload. The panel reference describes the Layout shifts track: purple diamonds sized by the score of each shift; hover one to highlight the element that moved, click it for the score, the timing, the elements and the likely culprits. The Insights tab runs the layout shift culprits check, which totals the CLS and offers a best guess among unsized images, injected iframes, animations and web fonts. For the at-a-glance version: Settings, More tools, Rendering, Layout Shift Regions, then reload, and everything that moves flashes purple.

For the numbers, paste the observer from web.dev's debugging article into the Console before reloading:

let cls = 0;
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.hadRecentInput) {
      cls += entry.value;
      console.log('Current CLS value:', cls, entry);
    }
  }
}).observe({type: 'layout-shift', buffered: true});

Each entry carries a value and a sources array of up to five elements that moved, each with a previousRect and a currentRect; hover node and DevTools highlights it. The article's reading rule: the element that moved is usually the victim, and the cause is the element immediately before it in the document, which changed size or was inserted. A large downward move is an insertion; a one or two pixel move is a font or a late stylesheet. Then use the page for five minutes with the Console open, swapping variants and scrolling to the reviews, because the loads the lab does not run are the ones the field records.

Fix it in this order, and it takes an afternoon

First, the images. Inspect the gallery img on the product page and read width and height off the element; if either is missing, the section is not using image_tag or an app is rendering its own. Then check the stylesheet for height: auto on the image and no fixed height on its container. Ten minutes to check, an hour for a developer to fix a custom section. A catalogue that mixes ratios is a re-crop.

Second, the injected content. For each app block in the product information section, measure the widget's height on desktop and on a 390-pixel viewport and set min-height in the section's Custom CSS. Half an hour per widget, no developer. For an app drawing a banner into the flow at the top of the page, the honest choices are an overlay, a position below the fold, or a different app.

Third, the fonts. Confirm font_display: 'swap' on every font_face call in theme.liquid and the preload links beneath them; set a unitless line-height on the body. Ten minutes. The fallback overrides are an afternoon for a developer with the font files, worth it only if the trace still shows the swap as the largest shift; optional or a system font is the two-minute version.

Fourth, the two nobody looks for: a section above the fold loading its stylesheet asynchronously, and anything animated by top or bottom. Both are a search of the theme code.

Fifth, the lab again, then the field. PageSpeed Insights on the same page should sit under 0.1 in the lab run at once and in the field data 28 days after the last fix. It is the third of the four numbers in section two of the audit checklist, and the one a script from an app you no longer use can move on its own, the app line in why your Shopify store is slow. If you would rather have it measured, ranked against everything else on the store and written up, that is the Store Teardown: €750, five days, and the document is yours whatever you decide to do with it.

Own your growth.Start with the teardown.

Start with a Teardown. Read it, then decide whether you want us to fix what it finds.