Modern View Transitions & Scroll APIs

The browser now ships a set of declarative motion primitives that execute natively on the compositor thread — eliminating the JavaScript scroll listeners, snapshot libraries, and FLIP gymnastics that dominated animation architecture for a decade. This topic covers four interlocking APIs: the View Transitions API for cross-state morphing, scroll-driven animation timelines for position-bound keyframes, @starting-style for deterministic entry effects, and container-query-scoped timelines for modular responsive motion. Taken together they form a CSS-first motion architecture that keeps the main thread free while meeting strict frame-budgeting targets of 16ms at every scroll position and navigation boundary.


Rendering-Pipeline Overview

The table below maps each major sub-topic on this page to the browser phase it primarily touches. Use it as a quick diagnostic when a technique produces jank: if a step touches layout or paint it needs special treatment before it can run on the compositor.

Sub-topic Browser phase Compositor-safe by default?
View Transitions snapshot + crossfade Composite Yes — pseudo-elements run on compositor
::view-transition-old/new keyframes Composite Yes, when limited to transform/opacity
animation-timeline: scroll() Composite Yes — scroll offset maps directly to compositor
animation-timeline: view() Composite Yes — subject intersection mapped off main thread
animation-range boundary clamping Composite Yes
@starting-style entry transitions Style → Composite Yes after first style pass
transition-behavior: allow-discrete Style Triggers one style recalc on toggle
Container query scroll scoping Style → Composite Yes once container size resolves

Rendering-Pipeline Architecture Diagram

Rendering pipeline for modern CSS motion APIs A horizontal flow diagram showing four browser phases: Main Thread (Style + Layout), Paint, GPU Upload, and Compositor. View Transitions, scroll-driven animations, and @starting-style are annotated at the phases they touch. MAIN THREAD Style @starting-style allow-discrete container size Layout avoid animating width / height / top PAINT VT snapshot captured here avoid background box-shadow ops GPU UPLOAD VT pseudo-element textures uploaded scroll timeline offset registered COMPOSITOR View Transitions crossfade ::vt-old / ::vt-new keyframes scroll() timeline view() timeline animation-range clamping transform / opacity interp. 60 fps — no JS overhead Target: all motion resolves within the 16.67 ms frame budget

View Transitions API

The View Transitions API implementation introduces a standardized model for animating between any two DOM states — whether inside a single-page app or across full document navigations. When document.startViewTransition(callback) is called, the browser captures a paint snapshot of the current state, runs the callback to update the DOM, then promotes both states to pseudo-elements (::view-transition-old and ::view-transition-new) on a dedicated compositor layer. The crossfade between them executes entirely on the compositor thread with no JavaScript polling per frame.

The ViewTransition object exposes two promises: ready fires once the pseudo-elements are in place (the right moment to apply custom CSS class names or trigger Web Animations API calls); finished fires after all animations complete and the pseudo-elements are removed. This two-gate lifecycle makes it safe to chain navigation logic, focus management, or analytics calls without guessing animation duration.

For cross-document transitions — where two separate HTML pages share an animated element — the browser matches elements by view-transition-name. Giving a shared hero image the same name on both pages produces a morphing effect with zero JavaScript. The critical constraint is memory: each named element spawns a separate GPU texture, so limiting view-transition-name declarations to the two or three elements that genuinely need morphing keeps VRAM consumption bounded. Consult the compositor-only property optimization guidelines before assigning names indiscriminately.

/* Named element: browser will morph this across the transition */
.hero-image {
  view-transition-name: hero;
}

/* Override the default crossfade with a scale morph */
::view-transition-old(hero) {
  animation: vt-scale-out 0.35s cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(hero) {
  animation: vt-scale-in 0.35s cubic-bezier(0.4, 0, 0.2, 1) both;
}

@keyframes vt-scale-out {
  to { opacity: 0; transform: scale(0.92); }
}
@keyframes vt-scale-in {
  from { opacity: 0; transform: scale(0.92); }
}

/* Root crossfade for surrounding content */
::view-transition-old(root) {
  animation: vt-fade-out 0.25s ease-out both;
}
::view-transition-new(root) {
  animation: vt-fade-in 0.25s ease-in both;
}
@keyframes vt-fade-out { to { opacity: 0; } }
@keyframes vt-fade-in  { from { opacity: 0; } }

/* Accessibility gate — must appear before any transition CSS */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root),
  ::view-transition-old(hero),
  ::view-transition-new(hero) {
    animation: none;
  }
}

Rendering Impact: composite — pseudo-elements live on the compositor layer. Restricting keyframes to transform and opacity keeps the entire transition off the main thread.


Scroll-Driven Animation Timelines

Scroll-driven animation patterns bind CSS keyframe progress directly to a scroll container’s position, replacing the brittle pattern of calculating scroll percentage in a scroll event listener and imperatively updating styles. The browser maps scroll offsets to animation progress on the compositor thread, so every frame is driven by the GPU with no JavaScript overhead per tick.

Two timeline types cover the common cases. scroll() maps the full scroll range of a chosen ancestor (defaulting to the nearest scrolling ancestor, or root for the document scroller) to 0%–100% animation progress. view() maps the subject element’s intersection with a scroll container: the animation starts when the element enters the container’s viewport and finishes when it exits, making it the native replacement for IntersectionObserver reveal patterns.

The animation-range property then clips which portion of that timeline drives the animation. animation-range: entry 0% cover 40% means “run the animation as the element scrolls from just entering the viewport to covering 40% of it.” Combining range clamping with composited transform and opacity produces parallax layers, sticky progress bars, and element reveal effects that run at 60fps even on low-end devices, because no main-thread code participates in the per-frame update loop.

/* Progress bar tied to page scroll — no JavaScript */
.read-progress {
  position: fixed;
  top: 0; left: 0;
  width: 100%; height: 3px;
  background: currentColor;
  transform-origin: left center;
  /* Animate scaleX from 0 to 1 as page scrolls */
  animation: grow-bar linear both;
  animation-timeline: scroll(root);
}

@keyframes grow-bar {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* Reveal card as it enters the viewport */
.reveal-card {
  opacity: 0;
  transform: translateY(24px);
  animation: reveal-up ease-out both;
  animation-timeline: view();
  animation-range: entry 0% cover 35%;
}

@keyframes reveal-up {
  to { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .read-progress  { animation: none; transform: scaleX(1); }
  .reveal-card    { animation: none; opacity: 1; transform: none; }
}

Rendering Impact: composite — scroll offset drives keyframe progress entirely on the compositor thread. No requestAnimationFrame or scroll event handler executes per frame.


Entry Effects & @starting-style

Before @starting-style, animating an element from display: none to visible required JavaScript to first apply a class that set display: block, wait a frame for the paint, then apply a second class that triggered the transition. The @starting-style rule eliminates this by declaring what property values the element holds before its first style is applied after insertion into the DOM.

When combined with transition-behavior: allow-discrete, the browser can now safely interpolate the display property itself. The element appears at the starting values defined in @starting-style, then transitions to its computed active values in a single frame cycle. The critical rendering detail: @starting-style fires only on first insertion or on the first style transition from display: none — it is not equivalent to a :hover state and does not re-fire on subsequent transitions. See the full implementation guide at CSS @starting-style & entry effects and a detailed example at using @starting-style for modal entry effects.

/* Modal that animates in on insertion — no JavaScript frame-skip hack */
.modal {
  display: none;
  opacity: 0;
  transform: scale(0.94) translateY(12px);
  /* allow-discrete enables display interpolation */
  transition:
    display    0.3s allow-discrete,
    opacity    0.3s ease-out,
    transform  0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.modal.open {
  display: flex;
  opacity: 1;
  transform: scale(1) translateY(0);
}

/* Define where the modal starts — before first paint */
@starting-style {
  .modal.open {
    opacity: 0;
    transform: scale(0.94) translateY(12px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .modal,
  .modal.open {
    transition: none;
  }
  .modal.open { display: flex; }
}

Rendering Impact: style → composite — one style recalculation fires when .open is toggled; the subsequent opacity and transform interpolation runs on the compositor.


Container Query Motion Triggers

Global viewport media queries break down once a component is reused in different layout contexts — a sidebar card and a full-width hero cannot share the same breakpoint-based animation trigger. Container query motion triggers solve this by scoping both size-based style decisions and scroll timeline references to the component’s own containing block rather than the document root.

Named scroll timelines allow a specific ancestor to be the timeline source rather than the nearest scrolling parent. Declaring scroll-timeline-name: --panel-scroll on a scrollable container and then referencing animation-timeline: --panel-scroll on a child element creates a completely self-contained scroll animation that remains correct regardless of where the component is placed in the layout tree. Combining this with @container size queries means the same component can apply motion only when its container is wide enough to benefit from it.

/* Scrollable panel owns its timeline */
.panel {
  overflow-y: scroll;
  scroll-timeline-name: --panel-scroll;
  scroll-timeline-axis: block;
  container-type: inline-size;
  container-name: panel;
}

/* Sticky header fades in only inside the panel's scroll context */
.panel__header--sticky {
  position: sticky;
  top: 0;
  animation: header-fade linear both;
  animation-timeline: --panel-scroll;
  animation-range: 0px 80px;
}

@keyframes header-fade {
  from { opacity: 0; backdrop-filter: blur(0); }
  to   { opacity: 1; backdrop-filter: blur(12px); }
}

/* Apply the animation only in large containers */
@container panel (inline-size < 480px) {
  .panel__header--sticky {
    animation: none;
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .panel__header--sticky {
    animation: none;
    opacity: 1;
    backdrop-filter: blur(12px);
  }
}

Rendering Impact: composite — named timelines are compositor-side. The single @container recalculation happens once at layout time, not per frame.


Performance Budget Summary

Metric Target Notes
Frame budget ≤ 16.67 ms Any composited animation must clear main-thread work before vsync
Recommended transition duration 150 ms – 400 ms Below 150 ms reads as instant; above 500 ms feels slow
Maximum named view-transition-name elements ≤ 8 simultaneous Each spawns a GPU texture; excess causes VRAM pressure
will-change budget ≤ 4 concurrent declarations Excessive use forces layer promotion and multiplies memory cost
animation-timeline named timelines Unlimited in isolation Nested timelines with shared scroll sources can cause scroll chaining; test per device
@starting-style style recalculations 1 per insertion No per-frame cost after initial paint

For a complete breakdown of compositor memory budgets and will-change lifecycle strategy, see layer promotion and will-change strategy.


Accessibility Gate

Every motion declaration on this site follows a cascade that treats reduced-motion as the default and adds animation as a progressive enhancement. The snippet below is the canonical pattern:

/*
  1. Define the component's resting (no-motion) state first.
  2. Layer motion only when the OS reports no preference.
  3. Never rely on a separate "reduced" block as the sole opt-out —
     the base state must always be readable and functional.
*/

.slide-panel {
  transform: none; /* functional resting state */
  opacity: 1;
}

@media (prefers-reduced-motion: no-preference) {
  .slide-panel {
    /* Animation applied only when motion is explicitly acceptable */
    animation: panel-enter 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
    animation-timeline: view();
    animation-range: entry 0% cover 40%;
  }

  @keyframes panel-enter {
    from { opacity: 0; transform: translateY(20px); }
  }
}

Vestibular disorders and photosensitive conditions make parallax and continuous scroll animations particularly harmful. Scroll-driven animations should never animate transform: rotate or large-magnitude translations inside animation-timeline: scroll(root) without a prefers-reduced-motion gate, since the user cannot pause a scroll-coupled animation by stopping interaction — it responds directly to their scroll gesture.


Common Pitfalls

  • Animating layout-triggering properties inside ::view-transition-old/new: Placing width, height, or padding keyframes on pseudo-elements forces a layout invalidation per frame, destroying compositor isolation. Use only transform and opacity.
  • Assigning view-transition-name to every element: Each named element creates a GPU texture upload. Applying the property to a dozen list items before navigation exhausts VRAM on mobile devices and stalls the transition for hundreds of milliseconds.
  • Omitting animation-range on view() timelines: Without range boundaries, the animation runs from when the element’s top edge enters the scrollport to when its bottom edge exits — a range that varies with element height. Always set explicit animation-range values for predictable behavior.
  • Missing transition-behavior: allow-discrete with @starting-style: The @starting-style block has no effect on display or overlay transitions unless allow-discrete is present in the transition shorthand. The element snaps to its final state instead of animating.
  • Unhandled startViewTransition() promise rejections: Rapid consecutive navigations can abort an in-progress transition, causing the promise to reject. Wrap the call in a try/catch or chain .catch() to prevent unhandled exception warnings and UI desync.
  • Nesting scroll() timelines without scroll chaining awareness: A child scroller with its own scroll timeline captures scroll events before the parent receives them. Explicit overscroll-behavior settings are needed to control chaining when stacking multiple scroll contexts.
  • Overusing will-change to “pre-optimize” View Transitions: The browser handles layer promotion for view-transition-name elements automatically. Adding will-change: transform on the same element doubles the layer cost without benefit.

FAQ

How do View Transitions and Scroll APIs affect Core Web Vitals?

Both APIs run on the compositor thread when restricted to transform and opacity, avoiding main-thread blocking. This keeps INP low by preventing forced synchronous layouts and keeps CLS near zero because the transition operates on a cloned layer rather than shifting live layout boxes.

Can scroll-driven animations replace IntersectionObserver for reveal effects?

Yes. animation-timeline: view() provides native, compositor-driven scroll binding for reveal effects. It delivers more consistent frame timing than observer callbacks (which queue microtasks on the main thread) and requires zero runtime JavaScript per frame. Use it for opacity and transform reveals; fall back to IntersectionObserver only for logic that requires JavaScript callbacks (lazy loading images, analytics beacons).

What is the recommended fallback strategy for unsupported browsers?

Wrap timeline declarations in @supports (animation-timeline: scroll()). The base styles must provide a fully readable, accessible static state — the animation is a progressive enhancement, not a functional requirement. Verify the static state looks correct before adding the @supports block.

Does @starting-style work with cross-document View Transitions?

@starting-style targets same-document element insertions only. For cross-document transitions the ::view-transition-new pseudo-element governs the incoming element’s entrance, so define its animation keyframes on ::view-transition-new rather than in @starting-style.

How do I audit motion performance in production?

Open Chrome DevTools Performance panel, record a scroll sequence or a navigation transition, then inspect compositor frames for Layout and Paint spikes above the 16.67ms line. The Layers panel shows which elements have been promoted; verify that only intentional view-transition-name or will-change: transform elements appear there. The Animations panel in DevTools displays scroll-driven timelines as they progress in real time, making it straightforward to confirm the correct animation-range boundaries.