How to fix Cumulative Layout Shift (CLS) in WordPress: find what moves and reserve its space
CLS adds up how far visible content jumps without the visitor causing it. On WordPress the cause is nearly always an image, embed or ad with no space reserved, a web font swapping in, a notice added at the top, or an animation. Find what moves, then reserve its space.
- By
- WP Ministry
- Published
In short
- Good is a CLS of 0.1 or less for at least three visits in four, judged on phones and on desktops separately.
- Find what moves before you change anything. Use the Layout shift culprits insight in PageSpeed Insights, or Layout Shift Regions in Chrome DevTools.
- WordPress adds width and height only to media library images that carry the wp-image- class. Images in templates, page builders, sliders and SVGs need them set.
- Reserve the space for whatever arrives late: an aspect ratio for embeds and video, a minimum height for ad slots.
- Show a cookie notice or an announcement bar over the page. Do not push it in at the top.
- A lab test sees only the first load of the first screen. Scroll the page yourself, logged out, at a phone's width.
Cumulative Layout Shift (CLS) adds up how far visible content moves when the visitor did nothing to move it. Something arrives late and pushes what is already on the screen. On a WordPress site it is nearly always one of five things: an image or video with no space reserved for it, an ad, embed or iframe, a web font swapping in, content a plugin adds at the top of the page, or an animation that changes the layout.
A good score is 0.1 or less. This page covers this one metric in depth. For all three Core Web Vitals and how they are judged, read the overview first.
How a shift is scored
When something visible changes position between two frames, the browser multiplies two fractions: how much of the screen the moving elements cover, before and after, and how far they moved compared with the screen's longer side. An element that fills half the screen and drops by a quarter of the screen's height covers 75% of it and moves 25%. That scores 0.75 × 0.25 = 0.1875, enough to fail the page by itself.
Four rules in the definition decide every fix on this page.
- Only things that were already visible count. A new element that appears without moving anything else is not a shift. So the cure is nearly always to reserve the space, or to draw the newcomer over the page.
- The score is relative to the screen. The same block pushed in at the top moves a larger share of a phone's screen than of a desktop's. That is one reason a page fails on phones and passes on desktops.
- CLS is the worst burst, not the total. Shifts less than a second apart are grouped into a window of up to five seconds. The page's score is the window with the highest sum.
- A shift within 500 milliseconds of a click, a tap or a key press is left out. Scrolling is not one of those. Anything that moves while the visitor scrolls counts in full.
See what moves
Do not guess. Each tool below names the elements that moved.
PageSpeed Insights
Run the page at pagespeed.web.dev. The section "Discover what your real users are experiencing" is field data from real visits. "Diagnose performance issues" under it is one lab load.
In the lab section, open the insight called Layout shift culprits. It lists the elements that shifted, each with its layout shift score, and where it can tell, a cause: "Unsized image element", "Web font" or "Injected iframe". Older reports and guides call this audit "Avoid large layout shifts". Lighthouse, the tool behind the lab section, replaced it with the insight in version 13. Two diagnostics may sit beside it: "Image elements do not have explicit width and height" and "Avoid non-composited animations".
The elements listed are the ones that moved. Often they are not the cause. Look at what sits directly above the first one: an image that grew, or something that was inserted.
When the page has too few visits of its own, the field section shows figures for the whole site. Check which you are looking at before you chase a problem that belongs to other pages.
Chrome DevTools
Step 1: Open the page logged out
Use a private window, so you get the page a visitor gets, with nothing cached. Open DevTools and make the page as narrow as a phone.
Step 2: Watch the live figure
The Performance panel opens on a live metrics view. It shows CLS for the page as you use it. Scroll slowly to the bottom and watch whether the figure rises.
Step 3: Record a reload
Record a trace of the page loading. The Layout shifts track shows each shift as a purple diamond, grouped into clusters. Click one. The Summary tab gives its score, the elements that moved and possible culprits.
Step 4: Switch on Layout Shift Regions
Press Command+Shift+P on a Mac or Control+Shift+P elsewhere, type
renderingand choose Show Rendering. Check Layout Shift Regions and reload. Every area that shifts is briefly highlighted in purple.Step 5: Slow the connection
Set network throttling in the Performance panel and reload. A slower connection makes a shift easier to see.
To log every shift as it happens, paste this into the Console and then use the page. Each line is one shift's score and the elements that moved.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
console.log(entry.value.toFixed(4), entry.sources.map((source) => source.node));
}
}).observe({ type: 'layout-shift', buffered: true });Why the lab shows 0 and the field fails
A lab test loads the page once and stops. It sees only the shifts that happen during loading, in the first screen. Field data covers the whole visit. These are the usual gaps.
- Shifts further down. A lazy-loaded image or iframe with no dimensions shifts the page when the visitor scrolls to it. The lab never scrolls.
- Content the lab is not shown. Targeted ads, A/B tests and anything personalized load late and differ per visitor. A consent notice can differ by the visitor's country.
- Slower phones and connections. The later a file arrives, the more is already on screen for it to push. The lab uses one device and one connection. Your visitors use many.
- Your own visit. Your browser has the site's images and fonts cached, and you may be logged in. What you see when you check the page is not what a first-time visitor gets.
The difference can also run the other way. Returning visitors have your images and fonts cached, so the field figure can be lower than a lab test run with an empty cache.
Search Console
The Core Web Vitals report groups pages that behave alike and gives each group a status. Open a CLS issue to see example addresses and the "Group CLS": the score that 75% of visits to the group were at or under in the last 28 days. Only indexed pages with enough data appear.
Use it to learn which kind of page fails: posts, product pages, an archive. Then put one address from that group through the tools above.
Fix it by cause
CSS on this page goes in "Additional CSS" or a child theme's stylesheet. How to add custom CSS shows where that is. PHP goes in the functions.php of a child theme.
After each fix, clear every cache and reload with Layout Shift Regions switched on and the connection throttled. The area that used to flash should stay still.
Images and video with no reserved space
When an image tag has a width and a height, the browser works out the picture's shape before the file arrives and keeps a space for it.
Since version 5.5, WordPress adds the two attributes when the page is sent, under three conditions. The image is in the media library. Its tag carries the wp-image- class with the attachment's number. And the tag has neither attribute already. Images a theme prints through wp_get_attachment_image() get both from the same record.
That leaves these without:
- An
<img>typed into a theme template, a widget or a Custom HTML block. - An image loaded from another site.
- A page builder widget or a slider that writes its own markup without the class, or builds it with a script after the page is drawn.
- SVG files. WordPress does not accept them as uploads by default. When a plugin allows them, there is usually no width and height on record to print.
Give each tag the dimensions of its file, in pixels and without units:
<img src="https://example.com/wp-content/uploads/team.jpg" width="1200" height="800" alt="The team at work">Then check the CSS. The browser can use the shape only once it knows a width. This rule is right:
img {
max-width: 100%;
height: auto;
}A rule that sets width: auto together with height: auto undoes the attributes. The browser has no width to work from, and the space collapses until the file arrives. Where a theme has such a rule, give the image a real width in CSS, such as width: 100% for a picture meant to fill its column. A fixed height in pixels stretches the picture instead.
In a template file of your own, print the image through WordPress and the attributes come with it. $image_id is the attachment's number.
<?php echo wp_get_attachment_image( $image_id, 'large' ); ?>When you cannot change the tag, as with a slider or a page builder widget, reserve the shape in CSS. Use the widget's own class and the ratio of your images.
.hero-slider img {
aspect-ratio: 3 / 2;
width: 100%;
height: auto;
object-fit: cover;
}A self-hosted video is the same case. Give the <video> tag a width and a height, or an aspect-ratio in CSS.
To confirm: view the page source and find the tag. It should carry both attributes. In DevTools, select the image. The Styles pane shows a line such as aspect-ratio: auto 1200 / 800 when the attributes are in use.
Lazy loading and image optimization have pages of their own.
Embeds, iframes, ads and maps
An embed often does not know its own size until it has loaded. The frame arrives, or grows, and pushes down what is under it.
Embeds from the block editor. The embed blocks mark each embed with its shape, for example wp-embed-aspect-16-9 wp-has-aspect-ratio. The styles that hold that shape apply only when the <body> tag has the wp-embed-responsive class. Block themes have had it by default since WordPress 5.9. A classic theme has to opt in:
add_action( 'after_setup_theme', function () {
add_theme_support( 'responsive-embeds' );
} );To confirm, view the page source and look for wp-embed-responsive in the <body> tag's classes.
An iframe you pasted in, such as a map or a booking form. Give the frame a class and let CSS hold its shape. height: auto is needed to override the height attribute most embed codes carry.
iframe.map-embed {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
border: 0;
}A widget that a script fills in later, including an ad. Reserve the height on its container:
.ad-slot {
min-height: 250px;
}web.dev's advice on ad slots:
- Reserve the most likely size. If the sizes vary, reserve the smallest and accept a small shift for a larger ad.
- Use media queries where the slot differs between phone and desktop.
- Do not collapse the space when no ad is returned. Removing reserved space shifts the page as much as inserting content does.
- If you cannot reserve space, place the slot lower on the page. A shift near the top moves more.
A shift inside another company's frame counts toward your page in Chrome's field data, and you cannot fix it from outside. You can only choose where the frame sits and how much room it gets.
Web fonts
Text is laid out in a fallback font first. When the web font arrives and takes up a different amount of space, lines rewrap and everything under them moves. This happens even where the text was kept invisible while waiting: the invisible text was holding the fallback's space.
font-display on each @font-face decides how long the browser waits and whether it swaps:
| Value | Text is invisible for | Swaps in the web font | Shifts |
|---|---|---|---|
block | Up to 2 to 3 seconds | Whenever it arrives | Possible |
swap | No time | Whenever it arrives | Possible, if the font is late |
fallback | Up to 100 milliseconds | Only within the next 3 seconds | Possible in that window |
optional | Up to 100 milliseconds | Never during this page view | None from fonts |
auto | The browser decides | The browser decides | Possible |
optional is the only value that rules out a font shift. The price is that a first-time visitor on a slow connection sees the fallback font for that page.
On a block theme, fonts are declared in theme.json and WordPress prints the @font-face rules for them. When a font face sets no fontDisplay, WordPress uses fallback. To change it, set the key on each font face in a child theme's theme.json, to the value you chose:
{
"version": 3,
"settings": {
"typography": {
"fontFamilies": [
{
"name": "Open Sans",
"slug": "open-sans",
"fontFamily": "\"Open Sans\", sans-serif",
"fontFace": [
{
"fontFamily": "Open Sans",
"fontWeight": "300 800",
"fontStyle": "normal",
"fontDisplay": "optional",
"src": [ "file:./assets/fonts/open-sans.woff2" ]
}
]
}
]
}
}
}The Font Library, added in WordPress 6.5, installs a Google Font by downloading the file to your own server. Since 7.0 it is under Appearance > Fonts on classic themes too. A font on your own server needs no connection to another company's. web.dev notes that this is not always faster in practice, so measure before and after.
On a classic theme, fonts are a stylesheet the theme enqueues. Put the font file and a small stylesheet in the child theme, and load the stylesheet:
@font-face {
font-family: "Open Sans";
src: url("open-sans.woff2") format("woff2");
font-weight: 300 800;
font-style: normal;
font-display: swap;
}add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_style(
'child-fonts',
get_theme_file_uri( 'assets/fonts/fonts.css' ),
array(),
'1.0'
);
} );Preload the one font the top of the page uses. A font that arrives before the first paint causes no shift. WordPress has had a filter for preloads since 6.1, and it works on both kinds of theme. Keep it to one or two files: every preload takes priority away from something else.
add_filter( 'wp_preload_resources', function ( $resources ) {
$resources[] = array(
'href' => get_theme_file_uri( 'assets/fonts/open-sans.woff2' ),
'as' => 'font',
'type' => 'font/woff2',
'crossorigin' => 'anonymous',
);
return $resources;
} );The crossorigin line is required even for a file on your own domain. Without it the browser ignores the preloaded font.
Make the fallback take up the same space. A second @font-face can reshape a font the visitor already has so that it matches the web font's line height. The three override values come from the web font's own metrics. These are the ones Chrome's documentation gives for Poppins.
@font-face {
font-family: "Poppins Fallback";
src: local("Arial");
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
}
body {
font-family: "Poppins", "Poppins Fallback", sans-serif;
}That fixes the height of each line. Android has no Arial, so a full setup adds a second fallback built on local("Roboto"), its system font. To match the width of the letters as well, add size-adjust. Its value depends on both fonts and has to be calculated. "Improved font fallbacks", in the sources, lists tools that produce all four values. A font face in theme.json accepts the same four as ascentOverride, descentOverride, lineGapOverride and sizeAdjust.
Icon fonts shift more than text fonts, because the fallback looks nothing like the icon. Replace them with SVG icons where the theme allows it.
To confirm: reload with the connection throttled. "Web font" should be gone from the Layout shift culprits list.
Content added at the top of the page
A cookie notice, a sale banner or a sign-up bar that is inserted above the page after it has been drawn pushes all of it down. web.dev calls a cookie notice at the top of the screen a very common source of layout shift.
There are two fixes.
Draw it over the page. An element that is not part of the page's flow cannot move anything. Look first for a position or layout setting in the plugin that shows the notice, and choose a bar at the bottom or a box. If there is no setting, CSS does it, using the notice's own class:
.cookie-notice {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
}Whether you need a notice at all is covered in GDPR and WordPress.
Or reserve its space from the start. A bar that must sit at the top should be in the page's HTML when the server sends it, with a min-height that fits its text at a phone's width. A bar added afterwards by a script cannot be reserved for unless an empty holder of the right height is already there.
The same reasoning covers three more cases:
- A sticky header that shrinks as you scroll. Scrolling does not excuse a shift. If the header is part of the flow and changes height, everything under it moves. Keep its box the same height and shrink what is inside it with
transform. - A menu that a script builds late. Give its container the height it will have.
- A notice that is dismissed. The page jumping up when the bar closes is fine if it happens within 500 milliseconds of the visitor's click. A bar that removes itself on a timer is a shift.
Animations that move the layout
Animating height, width, top or left makes the browser lay the page out again on every frame. Each of those frames can be a shift. transform moves or scales an element without touching anything around it.
A bar that opens with transition: height pushes the page down a little on every frame. This one slides in over the page and shifts nothing:
.promo-bar {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 1000;
transform: translateY(-100%);
transition: transform 0.3s ease;
}
.promo-bar.is-visible {
transform: translateY(0);
}An entrance effect from an animation plugin or a page builder may be built either way. Check it with Layout Shift Regions switched on.
Styles that arrive late
A stylesheet that loads after the first paint, or one that overrides rules already applied, changes the size and position of elements that are already on screen.
On WordPress, one way this happens is an optimization plugin's "critical CSS" or "remove unused CSS" feature. The page is first drawn with a cut-down set of styles, and the full set is applied afterwards. Wherever the two disagree, the element moves. A shift of only a pixel or two often points to conflicting styles or a font.
Switch the feature off, clear every cache and measure again. If the shift goes, regenerate the cut-down styles: a set made before the theme, a plugin or the page's design changed no longer matches the page. If the page still shifts with the feature back on, leave it off for that page. Fixing render-blocking CSS and JavaScript covers these settings.
What jumps, why, and the fix
| What you see | Why | The fix |
|---|---|---|
| Text drops when a picture appears, at the top or as you scroll | The image tag has no width and height, or CSS sets both to auto | Add both attributes. Use max-width: 100% with height: auto |
| A slider snaps to its height | A script builds the slides after the page is drawn | aspect-ratio on the slider's images or container |
| A gap opens around a video or a map | The embed arrives with no shape reserved | responsive-embeds on a classic theme. aspect-ratio on a pasted iframe |
| Content hops under an ad | The slot is sized when the ad arrives, or collapses when none does | min-height on the slot. Keep a placeholder |
| Lines rewrap a moment after the text appears | A web font replaced the fallback | font-display, a preload, a matched fallback |
| Everything moves down just after loading | A notice or bar was inserted at the top | Overlay it, or reserve its space in the HTML |
| The header twitches on scroll | A sticky header changes height | Keep the box the same height. Animate with transform |
| The whole layout settles a moment after first paint | A late or overriding stylesheet | Regenerate or switch off critical CSS |
What good looks like, and how long it takes to show
A CLS of 0.1 or less is good. Over 0.25 is poor. Between the two it needs improvement. The figure that is judged is the 75th percentile: three visits in four must be at or under 0.1. Phones and desktops are judged separately. You do not need zero.
After a fix, run the lab test and scroll through the page yourself straight away. That shows whether the cause has gone. Field data moves slowly. PageSpeed Insights updates it daily, but each figure covers the previous 28 days, so a fix shows in full about four weeks later. In Search Console, click Start Tracking on the issue's page once the fix is live. That starts a 28-day check of the affected pages. It does not make Google do anything sooner.
One thing helps beyond the fixes above. When a visitor presses Back, a browser can restore the page from its back/forward cache exactly as they left it, with no loading and no shifts. Some pages are not eligible. To check yours, open the Application panel in DevTools, choose its back/forward cache section and click Run Test. A failed test lists the reasons.
Our speed optimization service measures the pages you name before the work and again after it. It does not promise a particular score, because some limits sit with your hosting and with scripts loaded from other companies. How to speed up WordPress puts the rest of the work in order.
Common questions
Why is my CLS 0 in a test but failing in Search Console?
The test loads the page once and sees only the first screen while it loads. Search Console uses field data from whole visits, which includes shifts that happen when a visitor scrolls, ads and notices the test was never shown, and screens smaller than the one tested. Scroll the page yourself in a private window at a phone's width with Layout Shift Regions switched on.
Does font-display: swap fix layout shift?
No. It makes text visible sooner by showing the fallback font at once, and the page can still shift when the web font replaces it. To stop the shift, get the font there before the first paint with a preload, make the fallback take up the same space, or use optional.
Why does my site fail on mobile and pass on desktop?
A shift is scored as a share of the screen. The same banner or image moves a larger part of a phone's screen than of a desktop's, and the layout itself differs between the two. They are assessed separately, so fix the one that fails at its own width.
Do I have to add width and height to every image by hand?
No. WordPress adds them to media library images in posts and pages, and to images a theme prints through WordPress's own image function. Check the exceptions: images typed into templates or Custom HTML blocks, page builder widgets, sliders, SVGs and images loaded from other sites.
Does a cookie banner count toward CLS?
It does when it pushes the page down as it appears. A banner drawn over the page, at the bottom or as a box, moves nothing and adds nothing. The page moving when a visitor clicks to accept is not counted, as long as it happens within half a second of the click.
- Cost guideWordPress speed optimization cost: what the job is, what it should include and what moves the price
- GuideHow to speed up a slow WordPress site, in the order that pays off
- GuideHow to speed up a WooCommerce store
- GuideSlow WordPress admin: how to find the cause and fix it
- GuideWhy the WooCommerce dashboard is slow, and how to find the cause
- Guideadmin-ajax.php high CPU usage in WordPress: how to find what is calling it

