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
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 totransformandopacitykeeps 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. NorequestAnimationFrameorscrollevent 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.openis 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@containerrecalculation 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: Placingwidth,height, orpaddingkeyframes on pseudo-elements forces a layout invalidation per frame, destroying compositor isolation. Use onlytransformandopacity. - Assigning
view-transition-nameto 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-rangeonview()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 explicitanimation-rangevalues for predictable behavior. - Missing
transition-behavior: allow-discretewith@starting-style: The@starting-styleblock has no effect ondisplayoroverlaytransitions unlessallow-discreteis present in thetransitionshorthand. 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. Explicitoverscroll-behaviorsettings are needed to control chaining when stacking multiple scroll contexts. - Overusing
will-changeto “pre-optimize” View Transitions: The browser handles layer promotion forview-transition-nameelements automatically. Addingwill-change: transformon 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.
Related
- View Transitions API Implementation — lifecycle hooks, named elements, and cross-document morphing in detail
- Scroll-Driven Animation Patterns —
scroll()vsview()timelines,animation-rangerecipes, and polyfill strategy - CSS
@starting-style& Entry Effects — discrete property transitions, FOUC elimination, and browser support matrix - Container Query Motion Triggers — named timelines, self-scoped scroll contexts, and
@containerintegration - Performance Budgeting & GPU Architecture — frame budget targets,
will-changelimits, and compositor layer strategy - Core CSS Animation Fundamentals — keyframe architecture, timing functions, and hardware-accelerated properties