Detecting Layout Shift Regressions in CI
Part of Motion Testing & Automation in Accessible Motion Architecture.
The problem
A release adds an entrance animation to notification banners. The banner animates its height from zero, pushing the page content down as it grows, every time one appears. Nobody notices in review because the animation looks nice in isolation. In production, users report losing their place while reading, and the page’s Cumulative Layout Shift score worsens — but only on pages where a banner happens to appear.
Layout shifts caused by animation are easy to introduce, hard to see in review, and straightforward to catch automatically.
Root cause analysis: what counts as a shift
The Layout Instability API reports layout-shift entries when visible elements change position between frames without a user-initiated cause. Each entry has a value (the fraction of the viewport affected, weighted by distance moved), a hadRecentInput flag, and sources naming the elements that moved with their previous and current rectangles.
Shifts near an interaction are excluded from CLS. Entries within 500ms of a user input have hadRecentInput: true and are not counted toward the metric. That exclusion exists because a shift the user caused — expanding an accordion, for example — is usually understood. It does not mean the shift is harmless: a menu that pushes content when it opens is still disorienting, just not penalised.
Animation causes shifts in three common ways. Animating layout properties such as height, width or margin moves everything after the element on every frame. Inserting animated content without reserving space pushes content when it appears. And content that is revealed by removing display: none reflows the page in one step.
Transform and opacity never cause shifts. An element moved with translate does not affect layout, so no shift is recorded. This is the accessibility argument for compositor-only animation, on top of the performance one.
CI needs two windows. Load-time shifts and interaction-time shifts have different causes and different budgets; measuring them together hides regressions in both.
Step-by-step resolution
Production code pattern
// tests/perf/layout-shift.spec.js
import { test, expect } from '@playwright/test';
async function collectShifts(page, action) {
await page.evaluate(() => {
window.__shifts = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
window.__shifts.push({
value: entry.value,
recentInput: entry.hadRecentInput,
sources: entry.sources?.map((s) => s.node?.nodeName + (s.node?.id ? '#' + s.node.id : '')) ?? [],
});
}
}).observe({ type: 'layout-shift', buffered: true });
});
await action();
await page.waitForTimeout(800);
return page.evaluate(() => window.__shifts);
}
test('showing a notification banner does not shift the page', async ({ page }) => {
await page.goto('/dashboard');
const shifts = await collectShifts(page, () => page.click('[data-test=trigger-banner]'));
const total = shifts.reduce((sum, s) => sum + s.value, 0);
const worst = shifts.sort((a, b) => b.value - a.value)[0];
expect(total, `shift sources: ${worst?.sources.join(', ') ?? 'none'}`).toBeLessThan(0.02);
});
test('page load stays within the CLS budget', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
const shifts = await page.evaluate(() => window.__shifts ?? []);
const cls = shifts.filter((s) => !s.recentInput).reduce((sum, s) => sum + s.value, 0);
expect(cls).toBeLessThan(0.1);
});
/* The fix: reserve the banner's space and fade it in, instead of animating height. */
.banner-slot {
min-block-size: 3.5rem; /* space reserved even when empty */
}
.banner {
opacity: 0;
translate: 0 -8px;
transition: opacity var(--motion-enter-duration) linear,
translate var(--motion-enter-duration) var(--motion-enter-easing);
}
.banner[data-state="shown"] { opacity: 1; translate: 0 0; }
@media (prefers-reduced-motion: reduce) {
.banner { translate: none; transition: opacity 120ms linear; }
}
Rendering Impact: composite for the banner; the reserved slot means no layout change when it appears. Reserving space costs a little vertical room when no banner is present, which is usually a better trade than moving the page.
Including the shift’s source elements in the assertion message is what makes the failure fixable: “shift sources: DIV#banner-slot, MAIN” points straight at the cause, whereas a bare number sends someone to reproduce it by hand.
Interaction shifts still matter
Because shifts within 500ms of an input do not count toward CLS, a menu that pushes the page when opened scores zero and still disorients users — particularly those using screen magnification, for whom a shift moves content out of their magnified viewport entirely. Treat hadRecentInput entries as a separate, softer budget rather than ignoring them: measure them, watch the trend, and require a justification when a new one appears.
The clearest rule is the same one that serves performance: content that appears in response to an interaction should either occupy space that was already reserved, or be positioned above the page rather than in its flow.
Verification checklist
Constraints and trade-offs
- Shifts within 500ms of input are excluded from CLS but not from users’ experience.
- Reserved space is visible emptiness when the content is absent.
- CI viewport size affects shift scores; pin it.
- The Layout Instability API is not available in every browser.
- Small shifts below the budget can still accumulate into a poor experience on content-heavy pages.
Frequently asked questions
Do animations affect Cumulative Layout Shift?
Only animations that change layout. Transform and opacity animations never cause shifts; animating height, width or margins does.
Why does my menu animation not affect CLS even though the page moves?
Shifts within 500ms of a user interaction are excluded from the metric. They can still disorient users, so measure them separately.
How do I find which element caused a shift?
Read the sources array on each layout-shift entry; it names the nodes that moved, with their previous and current rectangles.
What budget should I use for interaction shifts?
Start near zero for content that appears in flow, and treat any new shift as something to justify rather than a threshold to approach.
Related
- Motion Testing & Automation — the parent topic
- Auditing Layout Shifts During CSS Transitions — diagnosing shifts by hand
- Enforcing Animation Performance Budgets in CI — the performance budgets alongside