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.

Where each modern motion primitive executesEvery primitive in this area is compositor-capable; the style column is what costs you on the toggle frame.Where each modern motion primitive executesStyle passCompositorWatch out forView transitionsnapshotsOne captureCrossfade runs hereLayout shift between snapshotsanimation-timeline:scroll()Resolved onceSampled per frameNon-linear easing distortsmappinganimation-timeline:view()Resolved onceIntersection off-threadRe-resolves per element@starting-styleentryExtra style passTransition runs hereNeeds allow-discrete fordisplay@container motiontriggersOn container resizeTransition runs hereNever animate the queried axis
Every primitive in this area is compositor-capable; the style column is what costs you on the toggle frame.
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
Transition duration budget for route-level motionRoute transitions are perceived as page latency, so they sit at the short end of the scale.Transition duration budget for route-level motionimperceptibleroute transitionfeels heavyblocks navigation0100200300400500600700800900 mscrossfade 200 msshared-element morph 350 msfull-page slide 600 ms
Route transitions are perceived as page latency, so they sit at the short end of the scale.

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.


Choosing between the four primitives

The four primitives in this area overlap enough that teams often reach for the wrong one and then fight it. They are separated cleanly by one question: what starts the motion?

A view transition starts when the DOM changes. It is the only primitive that can animate between two states of the page that never coexist — the old layout is a captured bitmap, the new one is live. That makes it the right tool for route changes, list-to-detail navigation and any “the same object moved somewhere else” morph, and the wrong tool for anything that has to respond continuously to input.

A scroll-driven animation starts and progresses with scroll position. There is no time component at all: progress is a pure function of offset, which is why the animation runs backwards when the user scrolls up and holds still when the user stops. Reach for it when the animation is the scroll feedback — progress bars, reveal-on-enter, sticky header states. Do not reach for it for one-shot effects that merely happen to be triggered while scrolling; that is what scroll-timeline ranges exist to bound.

An @starting-style entry effect starts when an element first becomes rendered. It is the only mechanism that can supply a before-state for an element that had no previous computed style, which is exactly the case for anything inserted into the DOM or flipped out of display: none. Its scope is deliberately narrow: one interpolation, from a supplied start to the resting style.

A container-query motion trigger starts when a component’s own box changes size. It answers a question no other primitive can hear — “how much room do I have?” — and it is the only one whose answer differs for two instances of the same component on one page.

The combinations that work well follow from those definitions. @starting-style handles a modal’s mount while a view transition handles the route change behind it; a container query decides whether a card animates its detail panel while a scroll timeline decides how far the panel has revealed. The combinations that fail are the ones that ask a primitive to observe something it cannot see: a container query cannot read scroll position, and a view transition cannot track a pointer.

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.


Do these APIs need a JavaScript framework to be useful?

No. Three of the four are pure CSS, and the View Transitions API needs one function call. A framework helps only with router integration — deciding when a navigation happens and what the new DOM is. Everything after that call is declarative, which is also why the motion keeps working when the framework’s hydration is still pending.

What happens in a browser that supports none of them?

Each primitive degrades to an instant state change rather than a broken layout, provided you feature-detect before adding transition-specific classes. A view transition that is unsupported simply swaps the DOM; an unsupported scroll timeline resolves animation-timeline to none and the animation holds its fill value; @starting-style is ignored and the element appears at its resting style. Test that path deliberately, because “no motion” and “no content” are one careless opacity: 0 apart.

Can these primitives be combined on the same element?

Yes, as long as each one owns a different phase of the element’s life. A card can mount with an @starting-style transition, reveal with a view() timeline as it scrolls into place, and then take part in a view transition when the user opens it — because those three never run at the same moment. What does not work is two primitives driving the same property at the same time; the last one to be applied wins, and the result depends on declaration order rather than intent.