How CLS is scored, and why product images dominate it
Every unexpected movement of a visible element produces a layout shift score, the product of two fractions:
- Impact fraction. The share of the viewport covered by the moving element's before and after positions combined.
- Distance fraction. How far it moved, as a share of the viewport's largest dimension.
Worked example: on an 800px-tall phone viewport, a 400px product image arrives without a reserved box and pushes the title, price and buy button (400px of content) down by 400px. The moved block spanned 0–400px before and 400–800px after, so the impact fraction is 800/800 = 1.0. It moved 400px of 800, so the distance fraction is 0.5. The score for that one shift is 1.0 × 0.5 = 0.5.
Shifts are grouped into session windows. A window closes after a one-second gap with no shifts, or after five seconds at most, and your page's CLS is the worst window rather than the sum of everything. Google assesses it at the 75th percentile of real visits.
The formula explains why product imagery dominates. Images are the largest elements on a PDP or collection page, so they produce the biggest impact fractions. They also load last, so they move things after the shopper has started reading. A late web font nudges a line of text by a few pixels. A late image moves half the screen.
Finding the image that actually moved
Lighthouse and a single PageSpeed Insights lab run only measure shifts during initial load, with no scrolling and no taps. Field CLS covers the whole visit, including scrolling a collection grid, opening a gallery and switching variants. That is why a store can show 0 in the lab and fail in the Chrome UX Report. If the numbers disagree, trust the field data and reproduce the visit by hand.
Three ways to attribute a shift to an element, from quickest to most thorough:
- Chrome DevTools, Performance panel. Record while you load, scroll and tap through a product page. Layout shifts appear as their own track, and selecting one highlights the nodes that moved.
- A console observer. Paste this into the console, then use the page as a shopper would. It logs every shift that counts, with the elements involved:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // excluded from CLS
console.log(entry.value.toFixed(3),
entry.sources.map(s => s.node));
}
}).observe({ type: 'layout-shift', buffered: true });
- Real-user attribution. The attribution build of Google's
web-vitalslibrary reports the largest shift target for each visit. Send it to your analytics and group by selector to see which template and element cause most of your field CLS.
The node reported is the element that moved, not the one that caused the move. If the logged node is your price block, look directly above it for the image, badge or carousel that changed size.
Where product images cause layout shift
After dimensions are declared on the main image, the remaining shifts almost always come from one of these patterns:
| Source | What the shopper sees | Counts toward CLS? |
|---|---|---|
| Gallery images with mixed aspect ratios | Buy button jumps when they swipe to a portrait shot | Yes, if the new image lands > 500 ms after the swipe |
| Variant swatch swaps the main image | Page height changes when a colour is picked | Often: slow image loads fall outside the input grace period |
| Carousel initialised by JavaScript | All slides stacked, then collapse into one | Yes, and usually the largest single shift on the page |
| Badges or review stars injected by apps | Image slides down as a label appears above it | Yes |
<picture> with different ratios per breakpoint | Wrong box reserved on mobile | Yes, if sources lack their own dimensions |
| Lazy-loaded grid tiles with no reserved box | Grid lurches while scrolling | Yes |
| "Load more" appending below the viewport | Nothing, since new tiles arrive off-screen | No |
The variant swap deserves attention because it catches careful teams. Shifts within 500 milliseconds of a tap are excluded because the shopper caused them. But if the new colour's image takes 800 ms to download and is a different shape from the old one, the resize lands outside that grace period and counts in full. On a slow mobile connection, every colour change becomes a scored shift.
Fixing galleries and variant swaps
The principle: the box belongs to the gallery, not to the image. Give the gallery container a fixed aspect ratio and let each image fit inside it. Changing which image is inside then cannot change the page's geometry.
.pdp-gallery__frame {
aspect-ratio: 4 / 5; /* your catalog's standard */
background: #f6f6f4; /* match your plate colour */
}
.pdp-gallery__frame img {
width: 100%;
height: 100%;
object-fit: contain; /* never crop the product */
}
Use object-fit: contain, not cover. Cover guarantees a filled box by cropping, which cuts off shoe toes and bag handles on any image that doesn't match the frame. Contain keeps the whole product visible and letterboxes the difference. If the letterboxing is visible, that is a sign the source images are inconsistent, not a CSS problem (more on that below).
For variant swaps, keep the old image on screen until the new one is ready to paint:
async function showVariant(img, url) {
const next = new Image();
next.src = url;
await next.decode(); // downloaded and decoded
img.src = url; // swap in one frame
}
Start the download earlier by preloading the image when a shopper hovers over or touches a swatch. That usually gets the swap inside the 500 ms grace period. Even when it doesn't, a fixed-ratio frame means there is nothing to shift.
Carousels, badges and the grid
Carousels. Most JavaScript sliders render their slides as a plain vertical list until the script runs, and then collapse them into one frame. On a PDP, that collapse is often the largest shift on the page. Style the pre-initialised state so it already looks like the finished carousel: a fixed-ratio frame showing only the first slide, with the rest hidden or laid out horizontally with overflow: hidden. A native CSS scroll-snap carousel avoids the problem entirely because it needs no initialisation to reach its final layout.
Badges and overlays. "Sale", "New", "Low stock" and review-star widgets are frequently injected by apps after the page renders. Anything that sits in normal document flow above or beside an image will push it. Position badges absolutely inside the image frame so they overlay without taking space. Reserve a fixed-height slot for review stars even before the widget loads.
Collection grids. Put the aspect ratio on the tile, not only on the <img>, so the tile keeps its size even when an image fails to load or a lazy loader swaps in a placeholder. With <picture>, add width and height to each <source> when breakpoints use different ratios. Current major browsers honour them, and without them the browser reserves the fallback image's shape at every size. The lazy-loading side of this, including which tiles to load eagerly, is covered in lazy loading product images without hurting LCP.
Shift-prone
- Image sets its own height from its file
- Swatch click sets
srcimmediately - Slider stacks slides until JS runs
- Badges in document flow above the image
Stable
- Frame owns a fixed aspect ratio
- Swap after
decode(), preloaded on hover - Pre-init CSS matches the final layout
- Badges absolutely positioned in the frame
Mixed aspect ratios are a content problem
Every fix above is easier when the images themselves agree. A catalog where some products were shot square, some portrait and some cropped by whoever uploaded them forces a trade-off on every template. You can fit them all in one frame and accept visible letterboxing, crop them to fit and lose parts of the product, or let each image set its own height and accept the shift.
Standardising the source images removes that trade-off. If every product image is exported on the same canvas, at the same ratio, on the same plate colour, one CSS rule reserves every box on every template. The placeholder colour matches every image, and a variant swap can't change the page's geometry because every variant has the same shape. Retouchable's product outputs finish with a deterministic framing step for this reason: it measures the cutout, scales it by a per-category rule and places it on a fixed canvas with an exact plate colour. The result is layout stability that doesn't depend on every template being coded defensively.
If re-exporting the catalog isn't practical yet, audit it first. Group your product images by aspect ratio, then fix the templates for the ratios that account for most images. Treat the outliers as a content backlog rather than a CSS problem.
A verification checklist before you ship
CLS fixes are easy to break with the next theme update or app install, so treat them like tests:
- Throttle and click. In DevTools, set network throttling to a slow mobile profile. Then load a PDP, swipe the gallery, change variants twice and scroll to the recommendations with the observer snippet running. Any logged value above about 0.01 deserves a look.
- Test the image that fails. Block one product image URL in DevTools and reload. The frame should keep its size with the placeholder colour, not collapse to zero height.
- Check each breakpoint. Art-directed
<picture>elements and responsive grids reserve different boxes at different widths. Test phone, tablet and desktop widths separately. The srcset and sizes guide covers how those variants are chosen. - Re-test after every app install. Review, badge and upsell apps are the most common way a stable page starts shifting again.
- Confirm in the field. Lab tests show what can shift. The Chrome UX Report and Search Console's Core Web Vitals report show what real shoppers experienced, with a lag of about four weeks for the 28-day window to roll over.
Layout shift is one of the few Core Web Vitals problems you can remove completely rather than just reduce. It comes down to geometry: when every image lives in a box whose size is known before any bytes arrive, product images have nothing left to move.