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.
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.
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.
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
PerformanceObserveronlayout-shiftentries withhadRecentInput === falselists 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.
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.
Related
- Core Web Vitals for Client-Side Apps β how CLS fits alongside LCP and INP in a SPA.
- Reducing Layout Shift During React Hydration β the initial-load counterpart to route-transition shift.
- Reducing LCP for Client-Rendered Hero Content β reserve space for the hero you are also painting earlier.
β Back to Core Web Vitals for Client-Side Apps