Core Web Vitals & Animated Interfaces
Scroll-driven animations and view transitions execute inside the exact machinery that Core Web Vitals instrument: the rendering pipeline, the main thread, and the frame presentation queue. A keyframe that animates top feeds CLS on every scrolled frame; a document.startViewTransition() call inserts snapshot capture and a DOM-update callback directly into the interaction latency that INP measures; an entry animation that fades a hero image up from opacity: 0 pushes back the paint that LCP reports. This guide maps each metric onto the animation feature that moves it, then builds the measurement and CI tooling to keep regressions out — part of the wider Animation Performance Profiling & Optimization section, and closely tied to how the View Transition API works under the hood.
Pages in this section
Metric reference: how each vital sees animation
The three Core Web Vitals are field metrics, scored at the 75th percentile of real user sessions. Each one intersects animated interfaces at a different pipeline stage:
| Metric | Good (p75) | Poor (p75) | Pipeline stage measured | How animation moves it |
|---|---|---|---|---|
| INP | ≤ 200 ms | > 500 ms | Interaction → next presented frame | startViewTransition() snapshot capture and the DOM-update callback run on the main thread inside the interaction’s frame |
| CLS | ≤ 0.1 | > 0.25 | Layout position deltas between frames | Keyframes animating top, margin, height shift positions on every scrolled frame; transform keyframes contribute nothing |
| LCP | ≤ 2.5 s | > 4 s | First paint of the largest content element | Entry animations starting at opacity: 0 postpone the candidate’s first visible paint |
Two structural facts follow from this table. First, CLS is the only vital a pure CSS scroll-driven animation can regress on its own — and only if the keyframes touch layout properties. Second, INP is the only vital a view transition regresses by design: the API deliberately pauses rendering between the old-state snapshot and the new-state snapshot, and that pause is charged to the interaction that triggered it.
Minimal working example
A scroll-driven reveal that cannot regress any vital, paired with an observer that proves it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* Compositor-only keyframes: no layout property appears anywhere */
.card {
opacity: 0;
transform: translateY(2rem);
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes reveal {
to { opacity: 1; transform: none; }
}
</style>
</head>
<body>
<main style="height: 300vh; padding-top: 80vh;">
<div class="card">Revealed on scroll — zero CLS contribution</div>
</main>
<script>
// Log any layout shift this page produces. With transform/opacity
// keyframes the callback should never fire during scroll playback.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.warn('Layout shift:', entry.value, entry.sources);
}
}
}).observe({ type: 'layout-shift', buffered: true });
</script>
</body>
</html>
Swap the keyframes to animate margin-top instead of transform and the observer starts logging a shift for nearly every frame of scroll — same visual effect, radically different CLS profile.
How scroll-driven animations affect CLS
A layout shift is recorded whenever a visible element’s start position changes between two rendered frames without a qualifying input. The scoring has two properties that matter enormously for scroll-driven work:
transformis exempt. Transforms are applied after layout, so an element sliding in viatranslateY()never changes its layout position and never produces alayout-shiftentry. The same movement expressed as animatedtopormargin-topchanges layout geometry on every frame and generates a shift entry each time.- Scrolling is not a “recent input”. The
hadRecentInputflag that excuses shifts within 500 ms of an interaction is only set by discrete inputs — clicks, taps, key presses. Scroll gestures do not set it. A scroll-driven animation that shifts layout is therefore charged full CLS even though the user “caused” it by scrolling.
The compound effect is brutal: a keyframe animating height over a view() timeline emits dozens of small shift entries as the user scrolls through the range, and CLS aggregates them into session windows (up to 5 seconds long, capped by 1-second gaps). A single badly-built reveal effect can push a page from 0.02 to 0.3 on its own.
The fix is categorical rather than incremental: express every scroll-linked movement through the compositor-safe pair transform and opacity. The full property-by-property breakdown lives in the compositor-safe properties and will-change budgeting guide, and the side-by-side comparison in animating transform vs top/left in scroll-driven effects shows the identical visual result with and without the CLS penalty.
/* CLS-hostile: layout changes every scrolled frame */
@keyframes slide-in-bad {
from { margin-left: -4rem; }
to { margin-left: 0; }
}
/* CLS-neutral: identical motion, applied after layout */
@keyframes slide-in-good {
from { transform: translateX(-4rem); }
to { transform: none; }
}
One subtlety: elements around the animated one matter too. Animating height on a collapsing header shifts every element below it — the shift entries attribute to the pushed content, not the animated element, which makes the regression hard to trace back without entry.sources.
View transitions and INP
INP decomposes an interaction into input delay, processing duration, and presentation delay, and reports the page’s worst interaction (roughly the 98th percentile when there are many). document.startViewTransition() inflates the second and third parts simultaneously, because the callback you pass it runs on the main thread inside the interaction, and rendering is paused around it:
- The click handler calls
startViewTransition(updateDOM). - At the next rendering opportunity, the browser captures snapshots of the old state for every element with a
view-transition-name— style, layout, and paint work, all main-thread or main-thread-adjacent. - The DOM-update callback runs. Rendering stays suppressed until the promise it returns settles.
- New-state snapshots are captured, the pseudo-element tree (
::view-transition-*) is built, and only then is the next frame presented.
Every millisecond of steps 2–4 lands inside the span INP measures for that click. A transition wrapping a route change in one of the SPA page-swap animation patterns typically adds 30–150 ms on desktop and multiples of that on low-end mobile — often the difference between a green 180 ms and a failing 400 ms interaction. The crossfade itself, by contrast, is nearly free: once the pseudo-elements exist, the default animations run on the compositor.
There is a second-order effect worth knowing: while the transition animates, the snapshot pseudo-element tree covers the viewport, so pointer events during the animation hit the overlay rather than the live DOM. Rapid consecutive interactions — a user tabbing quickly through a gallery — queue up against transitions still in flight and register progressively worse latencies. Duration discipline (200–350 ms) is an INP measure, not just a design taste.
Cross-document transitions declared with @view-transition have a different profile — there is no user-visible callback, but the new document’s first render is held until snapshots are ready. The trade-offs are covered in same-document vs cross-document view transitions. The step-by-step measurement and mitigation workflow for the same-document case is this section’s dedicated reference, listed at the top of this page.
LCP and entry animations
LCP reports the render time of the largest text block or image in the viewport. The interaction with entry animations is simple and unforgiving: an element is not an LCP candidate until it has actually painted visibly. If your hero image starts at opacity: 0 and fades in through an animation-range: entry reveal, its reported paint time moves from “when the bytes arrived” to “when the animation made it visible” — and Chrome treats elements that begin fully transparent conservatively, so the delay is real in field data, not just in theory.
Practical rules:
- Never enroll the LCP element in an opacity-from-zero entry animation. Let the hero paint immediately; animate secondary content around it.
transform-only entry effects are safer for LCP — a slighttranslateYon an already-visible image does not change its paint time — but they still cost CLS nothing and INP nothing, so prefer them when the design demands hero motion.- Cross-document view transitions interact with LCP on the destination page: render is briefly blocked while the incoming state is prepared. Keep the transition’s scope small (few named elements) so the block stays within a frame or two.
Because scroll-driven entry animations only play once the element enters the viewport, below-the-fold reveals are LCP-irrelevant. The risk is concentrated entirely in the first viewport.
Compositor-safe properties and their vitals footprint
The property you animate decides which threads do the work, and therefore which metric pays:
| Property | Compositor thread | CLS contribution | INP risk | Notes |
|---|---|---|---|---|
transform |
Yes | None | None | The default choice for all movement |
opacity |
Yes | None | None | Safe everywhere except the LCP element’s entry |
filter |
Mostly (Chromium) | None | Low | Blur radius changes can trigger repaint on some engines |
top / left / margin |
No | Every frame | Indirect (layout on main thread) | Never animate on a scroll timeline |
width / height |
No | Every frame, plus neighbors | Indirect | Shifts everything downstream |
background-color |
No | None | Low | Paint-only; costs frames, not position |
Verifying that an animation actually stayed on the compositor is a profiling task, not a code-review task — the workflow is in profiling scroll animations in Chrome DevTools. A scroll-driven animation that silently fell back to the main thread (because a non-compositable property snuck into the keyframes) degrades INP indirectly: every interaction that arrives while the main thread is busy re-laying-out inherits that busy time as input delay.
Measurement patterns: PerformanceObserver and web-vitals
Field measurement is the ground truth; lab tools cannot see your real users’ devices. Two layers of instrumentation cover the animation-specific cases.
Layer 1 — the metrics themselves. The web-vitals library pattern with attribution tells you which interaction and which shifted element:
import { onINP, onCLS, onLCP } from 'web-vitals/attribution';
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
// Attribution: the element/selector responsible
target: metric.attribution?.interactionTarget
?? metric.attribution?.largestShiftTarget
?? metric.attribution?.element,
});
navigator.sendBeacon('/vitals', body);
}
onINP(send);
onCLS(send);
onLCP(send);
Layer 2 — animation-specific context. Raw observers let you correlate a poor interaction with an in-flight view transition or a long animation frame:
// Slow interactions (Event Timing API)
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn(`${entry.name} took ${entry.duration}ms`,
'transition active:', document.documentElement
.classList.contains('vt-active'));
}
}
}).observe({ type: 'event', durationThreshold: 104, buffered: true });
// Long animation frames (Chromium 123+): script attribution for jank
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.duration > 100) {
console.warn('Long animation frame:', frame.duration,
frame.scripts?.map((s) => s.sourceFunctionName));
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
Set a vt-active class (or a performance.mark) around every startViewTransition() call so slow-interaction entries can be segmented by “transition ran / didn’t run” in your analytics. That single dimension answers the only question that matters: how much INP is the transition actually costing at p75?
Regression budgets in CI
Field data catches regressions after they ship; CI budgets catch them before. Two caveats shape the setup. CLS and LCP assert cleanly in lab runs; INP does not exist in a no-interaction lab load, so Total Blocking Time serves as its lab proxy, supplemented by scripted-interaction traces.
// lighthouserc.js — fail the build on animation-driven regressions
module.exports = {
ci: {
collect: { numberOfRuns: 3, url: ['http://localhost:8080/'] },
assert: {
assertions: {
'cumulative-layout-shift': ['error', { maxNumericValue: 0.05 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
// Lab proxy for INP headroom:
'total-blocking-time': ['error', { maxNumericValue: 200 }],
},
},
},
};
For the INP-shaped gap, drive a real interaction in Playwright and assert on the Event Timing entry it produces:
// Playwright: budget the interaction that triggers a view transition
await page.goto('/gallery');
await page.click('[data-nav="next"]');
const worst = await page.evaluate(() =>
new Promise((resolve) => {
new PerformanceObserver((list) =>
resolve(Math.max(...list.getEntries().map((e) => e.duration)))
).observe({ type: 'event', durationThreshold: 16, buffered: true });
})
);
expect(worst).toBeLessThan(250); // budget with CI-machine headroom
Budgets should be tighter than the metric thresholds — a 0.05 CLS budget against the 0.1 threshold leaves room for third-party noise in the field. Ratchet budgets down as you fix issues; never loosen one to make a build pass without a written trade-off.
Browser support and @supports guard
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
animation-timeline: scroll() / view() |
115+ | Behind a pref (Nightly) | In active development (Technology Preview) |
| Same-document view transitions | 111+ | 144+ in development | 18+ |
Cross-document @view-transition |
126+ | In development | 18.2+ |
layout-shift / event PerformanceObserver entries |
Yes | Partial (no Event Timing) | Partial |
long-animation-frame entries |
123+ | No | No |
Progressive enhancement is itself a vitals strategy: a browser that never runs the scroll animation can never pay its CLS cost, and a guard that skips startViewTransition() where unsupported keeps the interaction at its instant-swap baseline. Structure the CSS so the enhanced path is opt-in:
/* Baseline: fully visible, zero animation cost, zero CLS exposure */
.reveal { opacity: 1; transform: none; }
@supports (animation-timeline: view()) {
.reveal {
opacity: 0;
transform: translateY(2rem);
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
The broader guard patterns — including layering @media (prefers-reduced-motion) inside @supports — are collected in browser support and progressive enhancement.
Gotchas and failure modes
- The callback’s promise pauses rendering until it settles. Passing an
asyncfunction that awaits a fetch insidestartViewTransition()freezes the page — no rendering — until the response lands or the browser’s internal timeout (around four seconds in Chromium) aborts the transition. The entire freeze is charged to the triggering interaction’s INP. Fetch first, transition after. - CLS attribution points at the victim, not the culprit. When an animated
heightshifts the content below it, thelayout-shiftentry names the pushed elements. Useentry.sourcesand work upward to find the keyframe actually responsible. - Reduced-motion CSS overrides don’t remove snapshot cost. Setting
::view-transition-*durations to0.01mskills the crossfade but the browser still captures snapshots for every named element. On low-end devices that capture alone can blow the INP budget — skip thestartViewTransition()call in JavaScript instead. view-transition-namecount scales the main-thread bill. Each named element gets its own snapshot pair and pseudo-element group. Twenty named cards in a list can double capture time versus one named container. Name the minimum morphing set.- Lab CLS of zero doesn’t clear scroll-driven animations. Lighthouse’s default run never scrolls, so a
view()-timeline animation with layout keyframes shows CLS 0.0 in CI and 0.3 in the field. Add a scripted scroll to your CI trace, or lint keyframes for layout properties statically. - INP entries can attribute to the frame after the transition. Event Timing measures to the next presented frame; when rendering is paused, the “next frame” is the transition’s first frame, so slow interactions cluster suspiciously at your transition duration boundary. Segment analytics by the transition-active flag before concluding anything.
Performance checklist
- Animate only
transformandopacityon scroll timelines; treat any layout property inside@keyframesas a CLS bug. - Keep the LCP element out of opacity-from-zero entry animations; let the hero paint immediately.
- Prepare all state before calling
startViewTransition(); the callback should be a synchronous DOM swap. - Cap same-document transition durations at ~350 ms and keep
view-transition-nameassignments to the minimum morphing set. - Skip transitions entirely under
prefers-reduced-motion: reduceand on constrained devices — a JS guard, not just CSS duration overrides. - Ship
web-vitalsattribution beacons plus a transition-active flag so INP can be segmented by transition involvement. - Watch
long-animation-frameentries in Chromium for script-level attribution of animation jank. - Enforce CLS ≤ 0.05, LCP ≤ 2.5 s, TBT ≤ 200 ms in Lighthouse CI, plus a Playwright interaction budget for the view-transition path.
Related
- Diagnosing Main-Thread Jank in the Performance Panel — trace-level workflow for finding the code behind slow frames
- The Rendering Pipeline for Scroll Animations — style, layout, paint, and composite stages that the vitals instrument
- Implementing prefers-reduced-motion in Scroll-Driven Animations — the accessibility guard that doubles as an INP mitigation
- Implementing View Transitions for React Router — where transition calls sit in a real routing flow
- Understanding the CSS Scroll Timeline API —
scroll(),view(), andanimation-rangesemantics referenced throughout this guide