Fixing CLS from Responsive Image Swap and Breakpoints
Some layout shifts only appear on phones, or only on tablets, or only on a laptop with a particular window width. The page is stable on the screen you built it on, yet real-user data says otherwise.
The usual reasons are a picture element that serves a differently shaped image at some widths, a layout decided by JavaScript after the page has already been drawn, breakpoint styles that arrive late, and height based on viewport units that change as the browser toolbar moves. This guide shows how to find which width breaks, and how to make the box the same shape the browser first reserved.
What makes a responsive shift different
An image without any size information shifts at every width, which is covered in fixing CLS from images without dimensions. A responsive shift is more slippery: the page reserves a box that is right at one width and wrong at another. Because most people test on one desktop screen and perhaps one phone, the broken width slips through.
There are four common patterns:
- Art direction with different shapes. A
<picture>element shows a wide 21:9 banner on desktop and a taller 4:5 crop on phones, but the only dimensions in the markup describe the desktop file. On a phone the browser reserves a letterbox-shaped space, the tall image arrives, and the content below drops. - Layouts decided in JavaScript. A component checks
window.innerWidthafter load and switches from a desktop layout to a mobile one, or a slider measures its slides and sets their height once its script runs. - Breakpoint styles that arrive late. Critical CSS covers the desktop layout, and the rules for smaller screens sit in a stylesheet that loads afterwards. The page renders with desktop rules on a phone, then reflows.
- Viewport-based sizes that change. A hero set to a height in
vhordvh, or a desktop page where a scrollbar appears after content loads, changes size and moves everything after it.
What they have in common is that the box the browser drew first is not the box the page ends up with, and the difference depends on the screen.
When srcset is safe and when it is not
A plain srcset with a sizes attribute offers the browser the same
picture at different resolutions. Every candidate has the same proportions, so the width and
height attributes on the <img> are correct whichever file is picked. This kind of
responsive image does not cause shifting by itself.
<img src="/img/walk-800.jpg"
srcset="/img/walk-400.jpg 400w, /img/walk-800.jpg 800w, /img/walk-1600.jpg 1600w"
sizes="(min-width: 60em) 50vw, 100vw"
width="1600" height="1000"
alt="Coastal footpath above the bay">
Trouble starts when the candidates are different crops. That is art direction, and
it belongs in a <picture> element with a separate <source> per
breakpoint. If a developer has put differently shaped files into a single srcset, the attributes
can only be right for one of them. Move the crops into <source> elements, or re-crop
all candidates to one shape.
Dimensions on each source
Current browsers accept width and height attributes on each <source>
inside <picture>, and use the matching source's numbers to reserve the box. That is
the cleanest fix for art direction:
<picture>
<source media="(min-width: 64em)"
srcset="/img/sale-wide-2100.jpg"
width="2100" height="900">
<source media="(min-width: 40em)"
srcset="/img/sale-mid-1200.jpg"
width="1200" height="800">
<img src="/img/sale-tall-800.jpg"
width="800" height="1000"
alt="Autumn sale at Linden Street Interiors">
</picture>
The fallback <img> carries the dimensions for the narrowest crop, because it is
used when no media condition matches. Keep img { height: auto; } in your CSS
so the ratio controls the height.
A CSS backstop
If your templating makes per-source attributes awkward, or you need to support an older browser, set the ratio in CSS at the same breakpoints the sources use:
.sale-banner img { width: 100%; height: auto; aspect-ratio: 4 / 5; }
@media (min-width: 40em) { .sale-banner img { aspect-ratio: 3 / 2; } }
@media (min-width: 64em) { .sale-banner img { aspect-ratio: 7 / 3; } }
media
attributes on the sources. If the stylesheet switches at 40em and the markup at 42em, there is a band
of screen sizes where the box and the image disagree, and that band is where your shift lives.Finding the width that breaks
Start with real-user data, because it tells you which device class is affected. The Core Web Vitals report in Search Console splits results into mobile and desktop, and page groups there often point at a template. If only mobile is poor, test phones first; if only desktop, think about scrollbars, wide art-directed images and sidebars.
- Open the page in Chrome DevTools with the device toolbar turned on, set the width to a small phone, and enable slow network throttling with the cache disabled.
- Record a reload in the Performance panel. In the layout shift track, select each shift and check which elements moved.
- Repeat at each of your CSS breakpoints and a few pixels either side. Shifts that only occur in a narrow band usually mean markup and CSS use different breakpoint values.
- Run the CLS Debugger and compare the elements it names with what you saw at each width.
To see which art-directed file a picture element chose, and whether the reserved box matched it, paste this into the console after load:
document.querySelectorAll('picture img').forEach((img) => {
const box = (img.clientWidth / img.clientHeight).toFixed(3);
const file = (img.naturalWidth / img.naturalHeight).toFixed(3);
console.log(img.currentSrc, 'box', box, 'file', file,
box === file ? 'ok' : 'MISMATCH');
});
A mismatch after load usually means object-fit is cropping, which is fine; a mismatch
you can see as a jump during load means the reserved ratio was wrong.
The fix, step by step
Step 1
Find the screen widths where the shift happens
Check whether Search Console reports poor CLS for mobile, desktop or both, then reproduce the load in the DevTools device toolbar with slow throttling and the cache disabled. Record a reload at each breakpoint in your stylesheet and a few pixels either side of it. Note the widths where content moves and which element above it changed shape. The CLS Debugger's list of moving elements helps you confirm what you see.
Step 2
Give each art-directed source its own dimensions
For every <picture> element whose crops have different proportions,
add width and height attributes to each <source> using that
crop's real pixel size, and put the narrowest crop's dimensions on the fallback <img>.
If differently shaped files have been mixed into one srcset, split them into separate sources
with media conditions, or re-export every candidate so that all of them share the same shape.
Step 3
Match the reserved ratio to each breakpoint in CSS
Where attributes are not practical, or where a wrapper rather than the image
sets the height, set aspect-ratio in CSS inside media queries that use exactly the same
widths as the source media attributes. Keep height: auto on the image. Search
for rules that set a fixed height at one breakpoint only, because the browser applies them at some widths
and falls back to content height at others.
Step 4
Move layout decisions from JavaScript into CSS
Look for scripts that read window.innerWidth, call matchMedia
or measure elements, then change classes, heights or which component is rendered. Replace them with media
queries or container queries wherever possible. When a script must choose, such as a server-rendered
app, render both versions and hide one with CSS, or render the mobile version first so the first paint
already matches most visitors.
Step 5
Deliver breakpoint styles with the first paint
If you inline critical CSS and load the rest later, make sure the inlined part
includes the small-screen rules for everything above the fold, not only the desktop layout. Check stylesheets
loaded with a media swap trick or added by JavaScript, since any rule in them that changes
layout will reflow the page when it applies. Load layout rules normally and defer only decoration.
Step 6
Use stable viewport units and scrollbar space
Replace hero heights in dvh with svh, or with vh
plus a min-height, so the section does not resize as the mobile address bar collapses and
returns. On desktop layouts, add scrollbar-gutter: stable to the root element so the page
width does not change when content grows tall enough to need a scrollbar and the classic scrollbar appears.
Both changes are one line of CSS each.
Step 7
Test every breakpoint on a slow connection
Clear page and CDN caches, then repeat the throttled recordings at each breakpoint and either side of it, in portrait and landscape on a phone size. Re-run the CLS Debugger on the templates you changed. Look at the mobile and desktop Core Web Vitals figures separately over the following weeks, because an improvement on one device class can hide a problem that remains on the other.
JavaScript that rearranges the page
A large share of responsive shifts are not about images at all. They come from code that decides the layout after the first paint.
Mobile and desktop components chosen in script
In server-rendered apps, the server cannot know the screen width, so it renders a default, often the desktop navigation. When the page hydrates in the browser, a hook reads the width and swaps in the mobile menu. The header changes height and the page moves. The robust answer is to send markup that works at every width and let CSS decide what is visible:
<nav class="nav-desktop">โฆ</nav>
<nav class="nav-mobile">โฆ</nav>
.nav-mobile { display: block; }
.nav-desktop { display: none; }
@media (min-width: 60em) {
.nav-mobile { display: none; }
.nav-desktop { display: flex; }
}
Framework-specific patterns for this, including hydration mismatches, are in CLS in React and Next.js.
Sliders and carousels that measure themselves
Many carousel scripts show all slides stacked on top of each other until they initialise, then measure
the first slide and set the track's height. On a phone, where a slide might be twice as tall as on desktop,
the jump is large. Give the carousel container an aspect-ratio or min-height
per breakpoint, and hide slides after the first with CSS until the script is ready, so the uninitialised
and initialised versions take the same space.
Equal-height scripts
Older themes use JavaScript to make cards in a row the same height. On narrow screens, where cards stack in one column, the script may still run and change heights after load. CSS grid and flexbox give equal heights without any script; remove the old code.
Viewport units, toolbars and scrollbars
| Unit or setting | How it behaves | Stability |
|---|---|---|
vh | Based on the large viewport on most mobile browsers, so it stays the same as the toolbar moves. | Stable, but content can sit under the toolbar at first. |
svh | The small viewport, with toolbars fully shown. Never changes as they collapse. | Stable and fully visible. A good default for heroes. |
lvh | The large viewport, with toolbars hidden. | Stable. |
dvh | The dynamic viewport. Updates as the toolbar appears and disappears. | Changes during scrolling, moving anything below it. |
scrollbar-gutter: stable | Reserves room for a classic scrollbar even when the page does not yet scroll. | Stops the page narrowing when a scrollbar appears on desktop. |
The scrollbar issue is easy to miss. On Windows and some Linux desktops, scrollbars take up real width. A short page renders without one, then a late block of content makes the page tall enough to scroll, the scrollbar appears, the page narrows by around 15 pixels, text rewraps and blocks change height. Reserving the gutter from the start removes that reflow. Overlay scrollbars on macOS and phones take no width, so you will not see this on those devices.
Keeping responsive layouts stable
- Keep a single list of breakpoints. Define them once, in CSS custom media or design
tokens, and use the same values in markup
mediaattributes, stylesheets and scripts. - Treat art-directed images as a design decision. When a designer supplies different crops, record the ratio for each breakpoint alongside the files so developers can add matching dimensions.
- Test at awkward widths. Include a width just below and just above each breakpoint in your testing checklist, plus a common small phone and a tablet in landscape.
- Watch both device classes. Search Console reports mobile and desktop separately. A redesign that helps one can harm the other.
For the scoring rules behind all this, and how to rank shifts by impact, see the CLS Guide.
1. Why is my CLS poor on mobile but fine on desktop?
Phones are where the reserved box is most often wrong. Art-directed images frequently use a taller crop on small screens while the markup only describes the desktop image, mobile menus are often swapped in by script after load, critical CSS is sometimes written for the desktop layout only, and hero sections in viewport units react to the moving address bar. Mobile connections are also slower, which leaves more time between the first paint and the moment a late file or script changes the layout.
2. Does using srcset cause layout shift?
Not if every file in the srcset has the same proportions. In that case the browser is only choosing a sharper or smaller version of the same picture, and the width and height attributes on the img tag describe all of them correctly. Shifts happen when the files are different crops with different shapes. That is art direction, and it should use a picture element with a source for each breakpoint, each carrying its own width and height attributes.
3. Can I put width and height attributes on a source element?
Yes. Current versions of the major browsers accept width and height on source elements inside a picture element and use the dimensions of whichever source matches to reserve space. Put the narrowest crop's dimensions on the fallback img tag, since it is used when no media condition matches. If you need a belt-and-braces approach, also set aspect-ratio in CSS at the same breakpoints, making sure the media queries match the source media attributes exactly.
4. Does rotating a phone count as layout shift?
Rotating a phone changes the viewport, and the whole page is laid out again for the new width. That is a response to the device changing rather than content arriving late, and it is not the kind of problem this guide is about. What matters is that after rotation, images and components settle at their new size straight away instead of loading or measuring themselves again and jumping a second time, so test landscape loads as well as portrait ones.
5. Should I stop using dvh units?
Not everywhere, but avoid them for anything that other content sits below. The dvh unit updates as the mobile browser toolbar appears and disappears, so an element sized with it changes height while the visitor scrolls and moves everything after it. For a full-screen hero, svh gives a height that always fits with the toolbar shown and never changes. Use dvh for things like fixed overlays and full-screen menus, where nothing else is pushed around.
6. Why does my desktop layout jump by a few pixels sideways?
The most likely cause is a classic scrollbar appearing. On Windows and some Linux desktops, the scrollbar takes up real width. If the page is short when first drawn and becomes scrollable once more content loads, the scrollbar appears, the page narrows, and text rewraps. Adding scrollbar-gutter stable to the root element reserves the space from the start. You will not see this on a Mac or phone, which is why it is often missed during testing.
7. Is it better to use JavaScript or CSS for responsive layout switches?
CSS, wherever you can. Media queries and container queries apply before the first paint, so the page is drawn in its final shape. JavaScript runs later, after the browser has already laid out and often painted a default layout, so any change it makes can move content. Keep scripts for behaviour, such as opening a menu when tapped, and let CSS decide which layout, which component and which size each screen gets.
The CLS Debugger records every layout shift while your page loads, names the elements involved, calculates the cumulative score and ranks each shift by its impact, so you can compare what moves at each screen size.
Open CLS Debugger Fixes โ