Fixing Layout Shift During SPA Route Transitions

A single-page app navigates without a document reload, which is exactly why its layout shifts go unnoticed in testing and then dominate the field score. This guide, part of Core Web Vitals for client-side apps, explains why client-side route transitions inject content into a settled layout and how to reserve space so Cumulative Layout Shift (CLS) stays near zero across soft navigations.

Problem Statement

On a full page load the browser performs layout once against a known document. A client-side route change is different: the router unmounts the old view, mounts a new component subtree, and then β€” moments later β€” resolves the data that subtree needs. The browser lays out the new view twice or more, and every element that moves after the first paint of that view, without a user gesture to explain it, contributes to CLS.

Three mechanisms cause the overwhelming majority of route-transition shifts:

  • Async content injection. The new route paints a container immediately but fills it when a fetch resolves, pushing everything below the container down at that moment.
  • Skeletons without real dimensions. A placeholder that does not match the height of the content it stands in for guarantees a shift when the swap happens, regardless of how smooth the placeholder looked.
  • Font swap after paint. A web font that loads after the first text paint reflows the text to a different metric, nudging surrounding blocks.

Because these shifts happen on the same page instance across many navigations, the browser attributes them as soft-navigation CLS β€” a value a single cold lab load never records, but one that real users and the field report accumulate on every route change.

How a route transition shifts layout, and how reserved space prevents it Top row: an unsized route paints a header, then injects data that pushes the footer down and scores CLS. Bottom row: a correctly sized skeleton holds the space so the data swap causes no movement. Unsized route β€” content pushes down on data arrival header only data injected footer jumps down unexpected shift = CLS Sized skeleton β€” space reserved, swap is invisible skeleton at real height content, same height no movement CLS ~ 0
An unsized route pushes content down when data arrives; a skeleton at the content's real height makes the swap invisible.

Step-by-step fix

1. Reserve exact space before data arrives

The root cause of injection shift is that the container has no height until it is full. Give it the height it will eventually need using min-height or the aspect-ratio property, so the reserved box never grows when content lands. Every fix in this section maps back to one of the three shift sources named above, and each source has a single, direct remedy.

The three route-transition shift sources and their fixes Async content injection is fixed by reserving height, a mismatched skeleton by sharing one height token, and a late font swap by preloading with a size-adjusted fallback. Three shift sources, three fixes Async content injection Reserve height (min-height / aspect-ratio) Skeleton height mismatch Share one height token across states Late font swap Preload font + size-adjust fallback
Each transition shift traces to one of three causes, and each cause has a direct remedy β€” reserve, share, or preload.
function ProductPanel({ id }: { id: string }) {
  const { data, loading } = useProduct(id);

  // The wrapper reserves the panel's known height for both states,
  // so swapping the skeleton for real data moves nothing below it.
  return (
    <section style={{ minHeight: "320px" }}>
      {loading ? <ProductSkeleton /> : <ProductBody data={data} />}
    </section>
  );
}

2. Size skeletons from the real content, not an arbitrary box

A skeleton only prevents shift when its dimensions equal the content it replaces. Derive the placeholder’s height from the same layout tokens the real component uses.

const CARD_HEIGHT = 96; // one source of truth, shared by both states

function ProductSkeleton() {
  return (
    <ul aria-hidden="true">
      {Array.from({ length: 6 }).map((_, i) => (
        <li key={i} style={{ height: `${CARD_HEIGHT}px` }} className="shimmer" />
      ))}
    </ul>
  );
}

function ProductBody({ data }: { data: Product[] }) {
  return (
    <ul>
      {data.map((p) => (
        <li key={p.id} style={{ height: `${CARD_HEIGHT}px` }}>{p.name}</li>
      ))}
    </ul>
  );
}

3. Always give media intrinsic dimensions

Images and embeds injected on a route change are a classic shift source. Set explicit width and height attributes (or a CSS aspect-ratio) so the browser reserves the box before the resource loads.

// The width/height attributes let the browser compute aspect ratio and
// reserve layout space before the pixels arrive β€” no reflow on load.
<img src={hero.src} width={1200} height={630} alt={hero.alt} />

4. Stabilize fonts so text does not reflow

A late web font swaps metrics and nudges every text block. Preload the font and declare a metric-compatible fallback with size-adjust so the swap is visually and dimensionally neutral.

<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter.woff2") format("woff2");
  font-display: swap;
}
/* Fallback tuned to match Inter's metrics so the swap does not reflow text. */
@font-face {
  font-family: "Inter-fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
}

5. Wrap the transition so animation frames do not score

The View Transitions API animates between the old and new DOM, and its animation frames are excluded from CLS. Wrap the router update so the swap itself is smooth and unscored β€” while still reserving space for content that arrives afterward.

function navigate(update: () => void): void {
  // Progressive enhancement: fall back cleanly where the API is absent.
  if (!document.startViewTransition) {
    update();
    return;
  }
  document.startViewTransition(() => update());
}

Validation

Confirm the fix with both a live metric and a targeted check:

  • Measure soft-navigation CLS. Instrument the page and watch the value across route changes, not just the first load. A one-shot report will look clean while the field score is poor. This gap is exactly why the metric fools teams: the number a single cold load records and the number real users accumulate can sit on opposite sides of the threshold.
Cold lab CLS versus field soft-navigation CLS A single cold lab load records near-zero CLS while the field soft-navigation score climbs well past the 0.1 threshold, because only real route changes accumulate the shift. One cold load looks clean; the field does not CLS good ≀ 0.10 0.02 Cold lab load one navigation 0.28 Field soft-nav CLS many route changes
A one-shot lab load never triggers the soft navigations that accumulate CLS, so the field score can fail while the lab passes.
import { onCLS, type Metric } from "web-vitals";

onCLS((metric: Metric) => {
  // navigationType "soft-navigation" isolates route-transition shift.
  console.assert(metric.value < 0.1, `CLS regressed: ${metric.value}`);
  console.log(metric.navigationType, metric.value);
}, { reportAllChanges: true });
  • Record layout shifts directly. A PerformanceObserver on layout-shift entries with hadRecentInput === false lists exactly which nodes moved, so you can trace each shift to the route that caused it.
  • Throttle and navigate in DevTools. Under a slow network profile, click through the routes that fetch data and watch the Performance panel’s layout-shift markers; a correctly sized route shows none.

Common Pitfalls

  • Skeletons that are the wrong height. A placeholder shorter or taller than the real content shifts on swap; the animation only hid the problem in a fast local run.
  • Reserving space only for the happy path. Error and empty states often render at a different height than the skeleton, shifting when a fetch fails; size all three states alike.
  • Assuming View Transitions cover everything. The API excludes its own animation frames, but content that arrives after the transition settles with no reserved space still shifts and still scores.
  • Injecting banners or notices above the fold. A cookie notice or promo that mounts after paint pushes the whole page down; reserve its slot or render it in an overlay that does not participate in flow.
  • Testing only the cold load. The lab number will pass while soft-navigation CLS fails; measure the transitions, as emphasized across Core Web Vitals for client-side apps.
Five ways layout shift sneaks back into a SPA route transition A mis-sized skeleton, reserving only the happy path, trusting View Transitions alone, banners injected above the fold, and testing only the cold load each leave a shift the metric still counts. Five ways the shift sneaks back Skeleton at the wrong height shifts the moment real data swaps in Only the happy path reserved error and empty states differ in height Trusting View Transitions alone content after the settle still scores Banner injected above the fold pushes the whole page down after paint Testing only the cold load soft-navigation CLS stays hidden Reserve every state, measure every route the swap moves nothing and the field passes
Each red card is a shift the score still counts even after the obvious fix β€” the green card is the discipline that closes all five.

Frequently Asked Questions

Why does layout shift happen during a client-side route change but not a full page load? On a full page load the browser lays out a fresh document once. During a client-side route change the framework swaps components into a layout the browser had already settled, then async data arrives and pushes existing elements down. Because the movement is unexpected and the user did not cause it, each injection accrues Cumulative Layout Shift on the same page instance.

Do skeleton screens reduce or cause layout shift in a SPA? A skeleton reduces shift only if it occupies the exact dimensions of the content that replaces it. A skeleton that is shorter than the real content still forces a downward push when data arrives, and a skeleton that is taller collapses upward. Size the placeholder from the known content dimensions, not from an arbitrary loading box, and the swap produces no shift.

Does the View Transitions API fix Cumulative Layout Shift? The View Transitions API animates between two DOM states and its animation frames are excluded from Cumulative Layout Shift, so a transition itself does not score. It does not, however, excuse unsized content: if new content still arrives without reserved space after the transition settles, that late injection still shifts layout and still counts.

← Back to Core Web Vitals for Client-Side Apps