Fixing Layout Shift (CLS): Images, Fonts, and Stable UI in Real Apps
Layout shift is the moment a page moves under the user’s cursor: headlines jump, buttons slide, and the UI feels “cheap” even if it looks beautiful.
In Core Web Vitals, this is measured as CLS (Cumulative Layout Shift). The good news: most CLS issues come from a small set of causes you can fix systematically.
This post walks through a real-world approach to:
- find what’s shifting,
- fix it at the root,
- and verify the CLS improvement.
Before you start: reproduce and measure (don’t guess)
Use at least one of:
- Chrome DevTools → Performance panel (look for “Layout Shift” events)
- Lighthouse (in Chrome) for a baseline score
- Web-vitals instrumentation (if you have it in-app)
The point: identify which element is moving, and why.
The top 3 CLS causes (and fixes)
1) Images without reserved space
If the browser doesn’t know an image’s dimensions, it can’t reserve space. When the image loads, it pushes content down.
Fix:
<!-- Good: reserve space -->
<img
src="/hero.webp"
alt="Product hero"
width="1200"
height="630"
loading="eager"
decoding="async"
/>
If you don’t have fixed dimensions (e.g., CMS images), use CSS aspect-ratio:
.cardMedia {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
}
2) Fonts causing text to “reflow”
Web fonts can cause shifts when:
- the fallback font has different metrics than the final font,
- or the browser hides text until the font loads (FOIT).
Fix baseline:
/* If you self-host fonts */
@font-face {
font-family: "MyFont";
src: url("/fonts/MyFont.woff2") format("woff2");
font-display: swap;
}
Fix better (advanced): use metric-compatible fallbacks via size-adjust, ascent-override, descent-override, line-gap-override. This reduces visible jumps when the real font loads.
If you use Google Fonts, also ensure you’re preloading in a non-blocking way (your site already does this in src/components/MainHead.astro).
3) Dynamic content inserted above the fold
Common culprits:
- cookie banners / consent bars
- “announcement” bars
- async personalization / A/B content
- error banners that appear after fetch
Fix: reserve space or overlay instead of pushing content:
.topBanner {
position: sticky;
top: 0;
min-height: 48px; /* reserve space */
}
Or, for temporary banners, consider position: fixed with a safe body offset that’s applied immediately (not after hydration).
Practical debugging: “what moved?”
When you spot a shift:
- Inspect the element’s box model before/after.
- Look for late-applied CSS classes (e.g., async theme, font class, hydration toggles).
- Check if placeholders/skeletons have the same dimensions as the final content.
Verify the improvement
After fixes:
- rerun Lighthouse (same route, similar throttling),
- re-check DevTools Performance for fewer/smaller “Layout Shift” events,
- validate on slower devices or throttled CPU (shifts are easier to reproduce).
Closing
If you treat CLS like a checklist—images, fonts, dynamic insertions—you’ll fix most layout shift problems quickly. The key is to measure, fix at the source, and verify.