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


Where INP is measured during a view transition A left-to-right timeline of one interaction. After the user clicks, an input delay box is followed by a large main-thread block containing three sequential phases: capture old snapshots, the DOM update callback, and capture new snapshots. Style, layout, and paint follow, ending at the next presented frame. A bracket below spans from the click to that frame, labeled as the span INP measures. The crossfade animation runs on the compositor after the bracket ends. document.startViewTransition() main thread — rendering paused input delay capture old snapshots DOM update callback capture new snapshots style · layout paint · present crossfade on compositor user clicks next frame presented INP measures this entire span — snapshots and callback included

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:

  1. transform is exempt. Transforms are applied after layout, so an element sliding in via translateY() never changes its layout position and never produces a layout-shift entry. The same movement expressed as animated top or margin-top changes layout geometry on every frame and generates a shift entry each time.
  2. Scrolling is not a “recent input”. The hadRecentInput flag 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:

  1. The click handler calls startViewTransition(updateDOM).
  2. 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.
  3. The DOM-update callback runs. Rendering stays suppressed until the promise it returns settles.
  4. 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 slight translateY on 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

  1. The callback’s promise pauses rendering until it settles. Passing an async function that awaits a fetch inside startViewTransition() 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.
  2. CLS attribution points at the victim, not the culprit. When an animated height shifts the content below it, the layout-shift entry names the pushed elements. Use entry.sources and work upward to find the keyframe actually responsible.
  3. Reduced-motion CSS overrides don’t remove snapshot cost. Setting ::view-transition-* durations to 0.01ms kills 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 the startViewTransition() call in JavaScript instead.
  4. view-transition-name count 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.
  5. 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.
  6. 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 transform and opacity on scroll timelines; treat any layout property inside @keyframes as 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-name assignments to the minimum morphing set.
  • Skip transitions entirely under prefers-reduced-motion: reduce and on constrained devices — a JS guard, not just CSS duration overrides.
  • Ship web-vitals attribution beacons plus a transition-active flag so INP can be segmented by transition involvement.
  • Watch long-animation-frame entries 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.

Up: Animation Performance Profiling & Optimization