Stop layout shift on mobile (CLS): images, fonts, embeds
Pages that jump while loading hit phones hardest. Fix layout shift on mobile (CLS) with sized images, reserved space for embeds and banners, and matched fonts.
Layout shift is when content you're already looking at jumps because something above it loaded late: an image, an ad, a font, a cookie banner. To stop it on mobile, give every image, video and iframe its size before it loads, reserve space for embeds, ads and banners, use a fallback font sized like your web font, and never insert content above what people are reading. Google measures it as Cumulative Layout Shift (CLS), and a good score is 0.1 or less.
What CLS actually measures
The definitions come from web.dev's CLS guide:
- A layout shift happens when a visible element changes its start position between two frames. A new element appearing, or one growing, doesn't count on its own. It counts when it pushes other visible content.
- Each shift scores impact × distance. Impact is how much of the viewport the moving content covers, before and after. Distance is how far it moved, divided by the viewport's largest dimension (width or height).
- Shifts are grouped into bursts: shifts less than a second apart, up to 5 seconds in total. Your CLS is the worst burst.
- Shifts within 500 ms of a tap, click or key press don't count. Opening an accordion is fine. Scrolling doesn't count as input.
| CLS | Rating |
|---|---|
| 0.1 or less | Good |
| 0.1 to 0.25 | Needs improvement |
| Over 0.25 | Poor |
Google judges it at the 75th percentile of visits, not your best run on office Wi-Fi.
Why phones get it worse
Take a 60px promo bar that JavaScript inserts at the top after the page appears. Everything on screen moves down 60px, so the impact is roughly the whole viewport either way. The distance is where they differ:
| Screen | Largest dimension | Score for one 60px jump |
|---|---|---|
| Galaxy S25, 360×780 | 780 | 60 ÷ 780 ≈ 0.077 |
| Windows laptop, 1536×864 | 1536 | 60 ÷ 1536 ≈ 0.039 |
The same bar scores about twice as badly on the phone, and more once Chrome's toolbar takes some of those 780px. Two such jumps in one burst and the phone is past 0.1.
It gets worse in everyday ways too:
- One column. On a laptop, a late ad in the sidebar moves the sidebar. On a phone the same ad sits in the only column and pushes the whole article down.
- Narrow lines rewrap. A heading that fits on one line on a laptop might take two or three lines at 360px. If the web font arrives slightly wider than the fallback, one of those lines can spill onto an extra line, and everything below drops by a line height.
- Things arrive later. Slower connections and phone processors mean fonts, images and third-party scripts land after the first paint more often.
- Mobile-only extras. App install prompts, cookie notices and promo bars often get injected at the top, the worst possible place.
Fix 1: Give images and video their size
Put the real width and height on every <img> and <video>, and let CSS make them fluid:
<img src="/img/team.jpg" width="1600" height="900" alt="Our team at the workshop">
<video src="/video/demo.mp4" width="1280" height="720" controls></video>
img,
video {
max-width: 100%;
height: auto;
}
Browsers turn those two attributes into an aspect ratio, so a full-width image on a 360px phone gets a 360 × 202.5 box before a single byte arrives. More on sizing and srcset in responsive images with srcset and sizes.
When CSS decides the shape (thumbnails, cards, avatars), reserve it with aspect-ratio:
.card-thumb {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
In Tailwind that's aspect-video or aspect-square, or an arbitrary value like aspect-[4/3].
Fix 2: Iframes and embeds need CSS
The shortcut above doesn't work for iframes. The HTML spec only turns width and height into an aspect ratio on images, video, canvas and image buttons, so a YouTube or map iframe needs the ratio in CSS:
<iframe class="video-embed" src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
title="Product demo" width="560" height="315" allowfullscreen></iframe>
.video-embed {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
border: 0;
}
Embeds without a fixed shape (social posts, review widgets, third-party forms) need a slot with a min-height close to the height they usually end up. They're taller on phones, because narrow columns wrap more text:
.embed-slot { min-height: 480px; }
@media (min-width: 768px) {
.embed-slot { min-height: 400px; }
}
Those numbers are placeholders. Load the page at 360px and 440px, measure how tall the widget really gets, and use that.
Fix 3: Reserve space for ads, banners and cookie notices
Ads. Give each ad slot a min-height matching the ad size you serve at that width. If no ad comes back, keep the space with a placeholder instead of collapsing it, or the collapse is a shift of its own. web.dev also notes that content injected near the top of the viewport causes bigger shifts than content lower down (optimizing CLS), so put late-loading slots further down the page where you can.
Cookie notices and promo bars. Don't insert them at the top of the page. Either render them in the HTML from the start, so they're there on first paint, or show them as an overlay that doesn't move anything:
.cookie-banner {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 16px 16px calc(16px + env(safe-area-inset-bottom, 0px));
background: #fff;
box-shadow: 0 -2px 12px rgb(0 0 0 / 0.15);
}
A fixed overlay appearing isn't a layout shift, because nothing else moves. Keep it short, though: on an iPhone 17 Pro only about 718px of page is visible with Safari's bars on first load. The env() part keeps it clear of the home indicator when your viewport tag uses viewport-fit=cover (safe areas on iPhone).
Fix 4: Fonts that don't reflow the page
With font-display: swap, text shows in a fallback font straight away and switches when the web font arrives. If the two fonts take up different amounts of space, lines rewrap and the page shifts. Three things help:
- Preload the one font file you need first, so it arrives early.
- Make the fallback the same size with
size-adjustand the metric overrides (ascent-override,descent-override,line-gap-override), as Chrome's font fallback guide explains. - Or use
font-display: optional(MDN): if the font isn't ready almost immediately, the page keeps the fallback instead of swapping, and the cached font is there on the next page.
<link rel="preload" href="/fonts/brand.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
font-family: "Brand";
src: url("/fonts/brand.woff2") format("woff2");
font-display: swap;
}
/* A local font resized to take up the same space as Brand.
These percentages are placeholders: generate real ones for your font. */
@font-face {
font-family: "Brand Fallback";
src: local("Arial");
size-adjust: 104%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: "Brand", "Brand Fallback", sans-serif;
}
Don't guess the numbers: Next.js's next/font, Nuxt's Fontaine and the Capsize library calculate them for you. Two caveats. local() only works if the phone has that font installed, so check the result on an iPhone and an Android phone. And size-adjust works in all current browsers, but Safari doesn't support the three override descriptors yet (MDN), so on iPhones only the size adjustment applies.
Fix 5: Don't push content down once it's on screen
- No late inserts above the reader. "Free shipping" bars, "Open in app" prompts and newsletter boxes injected at the top after load are classic mobile CLS.
- Watch slow "Load more" buttons. Shifts within 500 ms of a tap are forgiven. If the new content takes longer to arrive, the shift counts, so show a placeholder of the right size immediately.
- Animate with
transform. Moving things withtop,marginorheightshifts their neighbours;transform: translate()andscale()don't. - Keep sticky headers one height. A header that shrinks as you scroll moves everything below it. Fix its height, or shrink it with
transform.
Fix 6: Skeletons that match the real thing
A skeleton (the grey placeholder boxes shown while content loads) only prevents shifts if it's as tall as what replaces it, at every width. Three lines of reviews on a laptop can be six lines on a 360px phone, so a skeleton sized on desktop still jumps on the phone. Give the skeleton a min-height per breakpoint, measured from the real content.
How to measure it
| Tool | What it tells you | Catch |
|---|---|---|
| Chrome DevTools Performance panel | Live CLS as you use the page, a list of each shift and what moved; a recorded trace adds a Layout shifts track (docs) | Your computer is fast: turn on CPU and network throttling and device mode |
| DevTools Rendering tab, Layout Shift Regions | Flashes purple where shifts happen | Visual only |
| Lighthouse | Lab CLS and the Layout shift culprits insight: unsized images, web fonts, injected iframes | Only measures the load, not shifts while scrolling |
| PageSpeed Insights | Real-user CLS from the Chrome UX Report (last 28 days, 75th percentile) plus a Lighthouse run | Needs enough traffic; Chrome users only |
| web-vitals library | CLS from your own visitors, with the element in the largest shift | Chromium browsers only |
To see which element moves while you develop, log it:
import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
console.log('CLS', value.toFixed(3), 'biggest shift:', attribution.largestShiftTarget);
}, { reportAllChanges: true });
reportAllChanges is for debugging; drop it in production. And remember the blind spot: CLS is only recorded in Chromium browsers, so your iPhone visitors' jumps never reach these numbers. Check Safari by eye.
Does your site fit?
Paste your address (or localhost:3000). doesitfit shows it on real screen sizes at once and tells you, bluntly, where it spills.
Free, no sign-up. Or ask Claude, ChatGPT or Codex to check it.
doesitfit doesn't measure CLS, but it shows how tall things end up at each width, which is what reserved space depends on. Open the page on a 360px Galaxy S25, a 402×874 iPhone 17 Pro and a laptop side by side, and size your min-height slots and skeletons from that. Why DevTools device mode isn't accurate explains why one simulated phone isn't enough.
A prompt for your AI tool
My pages jump while loading on phones (layout shift, CLS). Fix it across the site: give every img and video width and height attributes matching its real aspect ratio, with max-width: 100% and height: auto in CSS. Give iframes width: 100% plus a CSS aspect-ratio, and put third-party widgets and ad slots in wrappers with a min-height sized for 360px-wide phones. Show cookie and promo banners as fixed overlays at the bottom instead of inserting them at the top. Preload the main font file and add a size-adjusted fallback font (use next/font or Fontaine if the project already uses Next.js or Nuxt). Animate with transform instead of top, height or margin. Don't lazy-load the hero image and don't change the design. List each shift you fixed and what caused it.
Before launch, run through the vibe coding launch checklist and 10 responsive mistakes in AI-built websites too.
What to take away
- CLS should be 0.1 or less, and phones score worse for the same jump.
- Every image and video gets
widthandheight; every iframe gets a CSSaspect-ratio. - Reserve space for anything that loads late, sized for 360px phones, not your laptop.
- Banners go in the HTML from the start or float over the page, never get inserted at the top.
- Match your fallback font, preload the main file, and check iPhones by eye, because the numbers won't include them.
Questions people ask
What is a good CLS score?
0.1 or less, measured at the 75th percentile of page visits. Between 0.1 and 0.25 needs improvement, and above 0.25 is poor.
Why is my CLS worse on mobile than on desktop?
Phones show one narrow column, so anything that loads late pushes everything below it, often the whole screen. The distance part of the score is also divided by the screen's largest dimension, which is smaller on a phone, so the same jump scores higher.
Is CLS measured for iPhone visitors?
No. The Layout Instability API behind CLS only exists in Chromium browsers, so field CLS in PageSpeed Insights and the web-vitals library comes only from Chrome and other Chromium browsers. iPhone visitors still see the jumps; they just aren't in the numbers.
Do width and height attributes stop images being responsive?
No. With max-width: 100% and height: auto in your CSS the image still scales. The browser only uses the two numbers to work out the aspect ratio and reserve the right amount of space.
Does font-display: swap cause layout shift?
It can. When the web font replaces a fallback with different proportions, lines rewrap and the text below moves. A fallback tuned with size-adjust, or font-display: optional, avoids most of it.