DEV Community

Hamza Ahmad Aslam
Hamza Ahmad Aslam

Posted on Originally published at hamzaahmadaslam.com

Find the element causing WordPress layout shift

Originally published at hamzaahmadaslam.com.

How this article was made: drafted with AI assistance from a researched brief, then checked against primary documentation before publishing.

A WordPress layout shift is a moving-page problem, so start by capturing the shift event before changing CSS, fonts, plugins, or cache settings. Reproduce one visible jump, use Chrome to identify the elements that moved, then trace upward to the content or style change that pushed them.

How to trace a WordPress layout shift to the element

Open the affected URL in a clean browser session and reproduce the same path a visitor takes. Test a cold load first, then scroll, open the mobile menu, dismiss banners, and trigger any content that appears after load.

Chrome's Performance panel shows live CLS plus a Layout shifts table. A recorded trace also gives you the Layout shifts track. Since Chrome 131, that track uses purple diamonds, groups nearby shifts into clusters, and shows timings, scores, shifted elements, and possible culprits in the Summary view. The Chrome 131 DevTools notes document that panel change.

Start with the largest cluster you can reproduce. Click an individual shift and inspect the highlighted elements. Treat that list as evidence of what moved, not automatic proof of what caused the movement. An element inserted above a paragraph can push the paragraph down, so the paragraph may be listed even though the inserted element caused the shift.

A trace lets you fix WordPress CLS at the source instead of changing unrelated layout settings.

Capture each shift in the console

For a load-time shift, let the page finish the load you want to inspect, then paste this local diagnostic into DevTools Console. buffered: true gives the observer layout-shift entries that were recorded before the observer started.

For post-load shifts, such as banners, scrolling, or menu changes, paste it before triggering the behavior. Pasting the snippet and then reloading does not preserve it because navigation discards that page's JavaScript context.

const shiftObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;

    console.group(
      `Layout shift at ${entry.startTime.toFixed(1)} ms, value ${entry.value}`
    );

    for (const source of entry.sources ?? []) {
      console.log(source.node, {
        previousRect: source.previousRect,
        currentRect: source.currentRect,
      });
    }

    console.groupEnd();
  }
});

shiftObserver.observe({ type: 'layout-shift', buffered: true });
Enter fullscreen mode Exit fullscreen mode

hadRecentInput lets the diagnostic skip shifts close to qualifying user input. The LayoutShift reference defines that window as less than 500 milliseconds.

This snippet logs individual layout-shift entries. Do not add their values and call the result CLS. CLS uses the largest session window, where shifts are grouped with less than one second between them and a maximum window length of five seconds. Google's CLS optimization guide also warns that a shifted element may be the victim rather than the root cause.

Use attribution when the bad case happens only for visitors

The current web-vitals attribution build can identify the first shifted element, in document order, from the largest individual shift that contributed to CLS. It can also report that shift's time, value, and document load state.

A console version is useful only for local debugging. Its output stays in that browser's console, so it does not collect evidence from visitors. If your WordPress theme or plugin already has an npm build step, the local diagnostic can use the package import:

import { onCLS } from 'web-vitals/attribution';

onCLS(({ value, attribution }) => {
  console.log({
    cls: value,
    target: attribution.largestShiftTarget,
    time: attribution.largestShiftTime,
    shiftValue: attribution.largestShiftValue,
    loadState: attribution.loadState,
  });
}, { reportAllChanges: true });
Enter fullscreen mode Exit fullscreen mode

The bare web-vitals/attribution specifier is intended for application code processed by an npm and Node-based build tool. It will not resolve by itself in a typical directly enqueued WordPress script, and that static import is not a DevTools Console snippet.

For a live site without that build process, do not make the CDN example in the README a production dependency. The README says npm with a build process is the recommended route and shows unpkg, jsDelivr, and cdnjs only as examples. It says self-hosting the built files is better for security, reliability, and performance.

Instead, choose and pin the exact web-vitals 5.x release you intend to deploy. Copy its dist/web-vitals.attribution.js file into your theme or plugin and serve it from your own domain. WordPress introduced wp_enqueue_script_module() in version 6.5 for enqueueing script modules, so it can load a local collector module on supported WordPress versions.

The local paths below are illustrative. A collector module can import the self-hosted file rather than a floating @5 CDN URL:

import { onCLS } from './vendor/web-vitals.attribution.js';

onCLS(({ id, value, delta, attribution }) => {
  const body = JSON.stringify({
    id,
    value,
    delta,
    largestShiftTarget: attribution.largestShiftTarget,
    largestShiftTime: attribution.largestShiftTime,
    largestShiftValue: attribution.largestShiftValue,
    loadState: attribution.loadState,
  });

  navigator.sendBeacon('/analytics', body);
});
Enter fullscreen mode Exit fullscreen mode

The /analytics path is illustrative. Replace it with an endpoint you control and that is designed to receive these records. If you use a CDN for a temporary diagnostic instead, pin an exact package version rather than the floating @5 range.

This visitor-data version sends the metric instead of writing it to each visitor's console. It leaves reportAllChanges off because the current web-vitals documentation says that option is useful for debugging but is generally unnecessary and not recommended for production measurement.

Leaving that option off does not guarantee one callback per page load. The README says a metric callback can be called more than once. CLS should be reported whenever the page's visibility state changes to hidden, and metrics are reported again after a back/forward cache restore. A restored page gets a new metric id because the library treats that restore as a separate page visit.

Keep the id with every report. If your endpoint stores the full value, keep the latest value for each id instead of counting every callback as a separate metric. If you store delta instead, sum the deltas that share an id. That prevents repeated reports for one metric instance from skewing your visitor data.

Separate late content from font and styling changes

Once the trace gives you a time and a moved element, inspect what changed immediately before it. For this workflow, group WordPress layout shift causes into three buckets: late content, font metrics, or a styling change.

Evidence in the trace Likely cause What to inspect next
A banner, ad, embed, related-content box, or notice appears above moved content Late content Find the theme, plugin, tag, or custom script that inserts it. Check whether its container had space before insertion.
Text changes width, line breaks, or block height when a font request completes Font metrics Compare the fallback and final font. Check family, weight, line height, and font metric overrides.
The same DOM remains, but a header, menu, or content wrapper changes size after a class or stylesheet takes effect Styling change Compare computed styles before and after the shift. Check breakpoint rules, sticky-header states, and delayed CSS.

A web font can change layout because the fallback glyphs have different metrics. A line that fit before the font swap can wrap after it, moving everything below. The web.dev guidance recommends a suitable fallback and, when needed, metric controls such as size-adjust, ascent-override, descent-override, and line-gap-override.

Late content needs a different fix. If an ad, banner, embed, or popup enters normal document flow after paint, give its slot room before the content arrives. If an overlay does not need to push the page, keep it outside normal flow and confirm that it does not move existing visible content.

Use an annotated demo before changing the theme

The following artifact is illustrative. It creates a late banner above text so you can see what the trace is meant to reveal. The timer and dimensions are illustrative values, not measurements from a site.

<style>
  .demo-slot { background: #f3f3f3; }
  .demo-banner {
    min-height: 64px;
    display: grid;
    place-items: center;
  }
</style>

<div class="demo-slot" id="demo-slot"></div>
<p id="demo-copy">
  Illustrative paragraph that should move when late content is inserted above it.
</p>

<script>
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.hadRecentInput) continue;
      console.log('Illustrative shift', entry.value, entry.sources);
    }
  }).observe({ type: 'layout-shift', buffered: true });

  setTimeout(() => {
    document.querySelector('#demo-slot').innerHTML =
      '<div class="demo-banner">Illustrative late banner</div>';
  }, 1500);
</script>
Enter fullscreen mode Exit fullscreen mode

Expected: the paragraph moves when the banner is inserted. The trace may name #demo-copy because it moved, even though #demo-slot is the cause.

Now fix the symptom by reserving the illustrative banner height before insertion:

.demo-slot {
  min-height: 64px;
  background: #f3f3f3;
}
Enter fullscreen mode Exit fullscreen mode

Expected after the change: inserting the same illustrative banner no longer pushes the paragraph. On a production site, do not copy 64px blindly. Reserve the space required by that component at each breakpoint.

Reserve space without locking the layout

Use the smallest constraint that keeps the initial layout stable.

For known media or responsive embeds, an aspect ratio can reserve shape without forcing one fixed height:

.embed-slot {
  width: 100%;
  aspect-ratio: 16 / 9;
}
Enter fullscreen mode Exit fullscreen mode

For content whose height can grow, min-height is often safer than height. It reserves a floor but still lets longer text, translated text, larger user fonts, or a taller ad expand.

For a mobile header, reserve the header's initial closed-state height at the mobile breakpoint. Then inspect the logo, menu button, font swap, and sticky state separately. If the header grows only on mobile, inspect breakpoint-specific styles and content wrapping that the desktop layout does not use.

Do not treat every visible movement as measured CLS. Existing elements that are visible within the viewport and change their start position between two frames create layout-shift entries. Web.dev calls these unstable elements. Movement outside the viewport does not create a layout-shift entry.

A new element appearing does not count by itself unless it moves existing visible content. Shifts within 500 milliseconds of qualifying discrete input can be excluded. Scrolls, drags, and pinch gestures do not get that same recent-input treatment. Transform animations can move pixels without creating layout shifts because they do not run through layout.

There is also a measurement limit around iframes. Page-level Web APIs cannot see layout-shift entries inside an iframe, while CrUX can include iframe movement in the user experience. Keep that gap in mind when an embedded ad or widget looks unstable but your page script cannot attribute it.

Verify mobile and late interactions for regressions

A load-only test is not enough. Field CLS covers the page's full life, while a basic lab run usually focuses on initial load. The lab and field data guide explains why controlled tests and visitor data can disagree.

Use the same procedure before and after the fix. Every row below is illustrative, and each result is written as an expected outcome rather than an observed result.

Illustrative test Procedure Expected result
Cold mobile load Start with a fresh cache state, emulate the affected mobile layout, reload, and capture a trace No unexpected shift cluster from the header, font swap, banner, or reserved slot
Cold desktop load Reload at the desktop layout with the same content The fix does not add excess blank space or new wrapping
Mobile menu Open and close the menu, then keep the page open Menu behavior stays usable and no delayed shift appears after the interaction window
Scroll to late content Scroll to ads, embeds, related content, or lazy sections Reserved containers keep surrounding content in place
Font completion Reload with a fresh browser cache and watch font requests Final font rendering does not change line breaks enough to move surrounding content
Delayed banner or popup Trigger the same state that inserts the component The component overlays or fills reserved space without pushing existing content

If the Lighthouse lab diagnostics in PageSpeed Insights look clean but field CLS from PSI's real-user data, Search Console, or your own RUM data remains high, compare the interaction path, viewport, and field population before making another CSS change. Post-load shifts are one common reason the numbers differ.

Use the next trace to choose the right fix

If the trace points to the main image loading late rather than a moving layout box, continue with WordPress LCP image optimization. If your local trace and visitor data disagree, use why PageSpeed and visitor performance disagree to compare the measurements. If the cause spans theme CSS, plugin JavaScript, fonts, and responsive behavior, WordPress performance optimization is the relevant service path.

Top comments (0)