Scroll-Driven & View Transition Implementation Patterns
CSS Scroll-Driven Animations (W3C Scroll-driven Animations spec, shipping Chrome 115+ and Safari 17.4+) and the View Transitions API (WHATWG spec, shipping Chrome 111+ and Safari 18+) give you a declarative, compositor-first toolkit that replaces requestAnimationFrame scroll handlers and JavaScript page-transition libraries. Both APIs route work through the compositor thread’s role in frame budgets so motion stays smooth even under main-thread load.
Topic areas in this section
Building Scroll Progress Indicators
Reading-progress bars, section trackers, and any scroll-linked UI indicator — all wired to scroll() timelines without a line of JavaScript.
Parallax Effects with Pure CSS
Multi-layer depth effects driven by view() timelines. Compositor-safe velocity differentials with no requestAnimationFrame loop.
Sticky Header & Navigation Transitions
position: sticky + animation-timeline: scroll(root) working in concert — condensing headers, direction-aware show/hide, and animated state changes.
SPA Page Swap Animations
document.startViewTransition() integration with client-side routers, ::view-transition-old/new pseudo-element choreography, and cross-document transitions.
Cross-Route Element Morphing
Shared-element continuity across route changes using view-transition-name. GPU bitmap allocation strategy and globally-unique naming constraints.
Core concept
Both APIs share a fundamental model: instead of running JavaScript on every scroll event or page navigation, you declare intent in CSS and let the browser’s animation engine — specifically the compositor thread — drive the interpolation without touching the main thread.
Scroll-Driven Animations introduce two timeline functions:
scroll()— maps a scroll container’s offset to a0%–100%progress value.view()— maps an element’s intersection with its scroll container to a progress value keyed to entry and exit.
You assign one of these to animation-timeline on any element and the browser replaces time-based playback with position-based playback:
/* Canonical pattern: scroll-linked progress indicator */
.reading-progress {
animation: progress-fill linear both;
animation-timeline: scroll(root block); /* root scroller, block axis */
/* No animation-duration — scroll position is the clock */
}
@keyframes progress-fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
The View Transitions API captures DOM state before and after a JavaScript update and interpolates between the two snapshots:
// Canonical pattern: wrapped route update
async function navigateTo(url) {
if (!document.startViewTransition) {
await router.push(url); // graceful fallback
return;
}
const t = document.startViewTransition(async () => {
await router.push(url); // DOM mutation happens here
});
await t.finished; // resolves when animation completes
}
Elements with a unique view-transition-name value receive their own GPU bitmap and are morphed automatically between old and new geometry. The ::view-transition-group(name) pseudo-element controls timing and easing; ::view-transition-old(name) and ::view-transition-new(name) control content crossfade.
Rendering pipeline implications
Every browser animation frame has a ~16 ms budget (at 60 fps). The browser spends that budget in four sequential phases: style → layout → paint → composite. Work that triggers style or layout blocks the thread and can cause frames to drop.
Both APIs are specifically designed to operate only at the composite phase for supported properties:
| Property category | Phase triggered | Stays on compositor? |
|---|---|---|
transform (translate, scale, rotate) |
Composite only | Yes |
opacity |
Composite only | Yes |
filter (blur, brightness, etc.) |
Composite only | Yes |
clip-path |
Composite only (simple shapes) | Partially |
background-color |
Style + Paint | No |
width, height, padding |
Style + Layout + Paint | No |
border-radius |
Paint | No |
The practical rule: animate transform and opacity; simulate everything else with them. A header that “shrinks” on scroll should reduce padding via transform: scaleY() on an inner element rather than animating padding-block directly.
For scroll-driven animations, will-change: transform tells the browser to promote the element to its own compositor layer before the animation starts, avoiding the compositing cost at the first animated frame. Use it sparingly — each promoted layer consumes GPU memory.
For view transitions, every view-transition-name creates a GPU bitmap pair (old + new). Assigning names to elements that do not need to morph wastes GPU memory and can stall the transition start if bitmap capture is slow on large DOM trees.
Browser support matrix
| Feature | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
animation-timeline: scroll() |
115 (Jul 2023) | 17.4 (Mar 2024) | 110 (flag) / unsupported | 115 (Jul 2023) |
animation-timeline: view() |
115 (Jul 2023) | 17.4 (Mar 2024) | unsupported | 115 (Jul 2023) |
animation-range |
115 (Jul 2023) | 17.4 (Mar 2024) | unsupported | 115 (Jul 2023) |
Named scroll-timeline / view-timeline |
115 (Jul 2023) | 17.4 (Mar 2024) | unsupported | 115 (Jul 2023) |
document.startViewTransition() (same-document) |
111 (Mar 2023) | 18.0 (Sep 2024) | 131 (Nov 2024) | 111 (Mar 2023) |
::view-transition-* pseudo-elements |
111 (Mar 2023) | 18.0 (Sep 2024) | 131 (Nov 2024) | 111 (Mar 2023) |
@view-transition (cross-document) |
126 (Jun 2024) | 18.2 (Dec 2024) | unsupported | 126 (Jun 2024) |
view-transition-name on :root |
111 (Mar 2023) | 18.0 (Sep 2024) | 131 (Nov 2024) | 111 (Mar 2023) |
Firefox ships view transitions but not scroll-driven animations as of mid-2026. Any scroll-driven animation that cannot degrade gracefully requires a JavaScript fallback for Firefox users. A polyfill for scroll-timeline exists and is covered under progressive enhancement strategy below.
Progressive enhancement strategy
Gate every scroll-driven declaration behind @supports so browsers without the API receive a static, fully-readable baseline state:
/* Baseline: element visible, no animation */
.scroll-reveal {
opacity: 1;
transform: none;
}
/* Enhanced: animate on scroll */
@supports (animation-timeline: scroll()) {
.scroll-reveal {
opacity: 0;
transform: translateY(1rem);
animation: reveal-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
@keyframes reveal-in {
to {
opacity: 1;
transform: none;
}
}
/* Accessibility: honour reduced-motion at the timeline level */
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
/* Reset the timeline entirely, not just animation: none */
animation-timeline: auto;
animation: none;
opacity: 1;
transform: none;
}
}
The critical detail: @media (prefers-reduced-motion: reduce) must reset animation-timeline: auto in addition to animation: none. Setting animation: none alone suppresses the keyframes but leaves the timeline wired, which can cause the element to stay invisible if animation-fill-mode: both was set.
For JavaScript, mirror @supports with CSS.supports():
if (CSS.supports('animation-timeline', 'scroll()')) {
// Scroll-driven path — no JS handler needed
} else {
// IntersectionObserver fallback
const observer = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) e.target.classList.add('is-visible');
});
}, { threshold: 0.1 });
document.querySelectorAll('.scroll-reveal').forEach(el => observer.observe(el));
}
For Firefox support of scroll-driven animations without native API support, the scroll-timeline polyfill (by Google’s Robert Flack) layers a MutationObserver + ResizeObserver implementation over the native API surface. Add it conditionally:
if (!CSS.supports('animation-timeline', 'scroll()')) {
await import('/js/scroll-timeline-polyfill.js');
}
// From here, both native and polyfill environments expose the same CSS API
Framework integration notes
React
React’s reconciler re-renders components by applying inline style mutations. If your component sets style={{ transform: ... }} on the same element that has animation-timeline, the inline style wins and overrides the animation output. Two patterns avoid this conflict:
// Pattern A: separate the animated element from the styled element
function AnimatedCard({ children }) {
return (
// Outer div carries the scroll-driven animation
<div className="scroll-reveal-wrapper">
{/* Inner div receives React-managed styles safely */}
<div className="card-content" style={{ color: theme.color }}>
{children}
</div>
</div>
);
}
// Pattern B: use CSS custom properties to pass React state into CSS
function AnimatedCard({ progress }) {
return (
<div
className="progress-card"
style={{ '--js-progress': progress }} // CSS var, not transform
>
{/* CSS: .progress-card { transform: scaleX(var(--js-progress, 0)); } */}
</div>
);
}
For view transitions in React, wrap router update calls rather than DOM mutations directly. React 18’s startTransition and document.startViewTransition can be nested — call startViewTransition in the outer layer and startTransition inside the update callback to prevent React batching from resolving before the DOM is stable:
document.startViewTransition(() => {
ReactDOM.flushSync(() => {
setRoute(nextRoute); // force synchronous render before snapshot
});
});
Vue
Vue’s <Transition> component applies enter/leave classes that can conflict with view-transition-name if both target the same element. Keep view-transition-name on a wrapper element outside <Transition>, or disable Vue’s transition on routes where you are using the View Transitions API.
keep-alive caches component instances including their scroll position. When a cached component is reactivated with a scroll-driven animation already in the animation-fill-mode: both state, the animation can snap to the filled end state instead of replaying. Reset the animation on onActivated:
onActivated(() => {
// Force animation to replay from scroll position
el.value.style.animation = 'none';
el.value.offsetHeight; // trigger reflow
el.value.style.animation = '';
});
Svelte
Svelte’s built-in transition: directive applies transform and opacity directly on the element. If you also have animation-timeline on the same element, Svelte’s directive will override the scroll-driven output during the transition frame. Prefer Svelte’s use: action pattern to attach scroll-driven animations so they live in CSS and Svelte’s directives operate on a sibling element:
// scroll-reveal.js — Svelte action
export function scrollReveal(node) {
node.classList.add('scroll-reveal');
return {
destroy() { node.classList.remove('scroll-reveal'); }
};
}
Debugging workflow
Scroll-Driven Animations
- Open DevTools → Animations panel (Chrome DevTools:
Ctrl+Shift+I→ three-dot menu → Animations). Scroll-driven animations appear as a timeline scrubber — you can drag the playhead and see keyframe interpolation without scrolling. - Check the Layers panel (
Ctrl+Shift+I→ Rendering → Layer Borders). Elements withwill-change: transformor activeanimation-timelineshould show as separate compositor layers (green border). If no layer appears, the element is not promoted and transform animations fall back to the main thread. - Common error: animation stays frozen. Cause:
animation-timelineis set but the element is not inside the referenced scroll container. Fix: verify the element’s scroll ancestor matches thescroll()argument (rootfor the document scroller, or pass the element reference for a named timeline). - Common error:
animation-rangehas no effect. Cause:animation-rangerequiresanimation-timelineto be set first; CSS cascade order matters. Fix: placeanimation-rangeafteranimation-timelinein the rule, or use shorthandanimationproperty carefully (timeline is the last value).
View Transitions
- Freeze the transition. In DevTools → Performance panel, start recording, trigger the transition, then record a screenshot. Or use
t.ready.then(() => new Promise(r => setTimeout(r, 10000)))in thestartViewTransitioncallback to artificially slow the transition to 10 s, making the pseudo-elements inspectable. - Inspect pseudo-elements. During an active transition, open Elements panel. The
htmlelement will contain::view-transition > ::view-transition-group(root) > ::view-transition-image-pair(root). Named elements appear as additional::view-transition-groupsiblings. - Common error: morph does not animate. Cause:
view-transition-namevalue on old DOM and new DOM do not match exactly (case-sensitive). Or the named element wasdisplay: noneat capture time — hidden elements are not snapshotted. - Common error: flash of unstyled content between snapshots. Cause: the update callback resolves before async resources (fonts, images) finish loading. Fix: await all critical resources inside the callback before it returns.
- Common error:
InvalidStateErroronstartViewTransition. Cause: a previous transition is still running. Fix: store the transition object and callt.skipTransition()before starting a new one.
Key concepts index
scroll()
: Timeline function that maps a scroll container’s block or inline offset to animation progress. Syntax: scroll(<scroller> <axis>) where scroller is root, nearest, or self, and axis is block, inline, x, or y. Default: scroll(nearest block).
view()
: Timeline function that maps an element’s intersection with its scroll container to animation progress. Syntax: view(<axis> <inset>). The subject element is the element bearing animation-timeline: view().
animation-timeline
: CSS property (longhand of animation) that replaces the default auto (time-based) timeline with a scroll or view timeline. Accepts a <single-animation-timeline> value.
animation-range
: CSS shorthand for animation-range-start and animation-range-end. Clips which portion of the scroll progress activates the animation. Accepts normal, percentages, or keyword+percentage pairs like entry 0% and exit 100%.
view-transition-name
: CSS property that opts an element into the View Transitions API snapshot/morph pipeline. Must be unique across the document at transition time. Value none removes the element from participation.
::view-transition-group(<name>)
: Pseudo-element wrapping the old/new image pair for a named element. Controls the position and size interpolation. Responds to animation and transition CSS applied by the author.
animation-fill-mode: both
: Makes an animation apply its from keyframe before it starts and its to keyframe after it ends. Required for scroll-driven reveal patterns so elements stay revealed after scrolling past the entry range.
contain: layout
: Required companion to view-transition-name on elements that use contain: strict. Using contain: strict blocks GPU snapshotting; contain: layout is the safe alternative that still provides containment benefits.
@view-transition
: At-rule enabling cross-document (MPA) view transitions. Placed in the CSS of both the old and new page with navigation: auto. Requires Chrome 126+ or Safari 18.2+.
scroll-timeline-name / view-timeline-name
: CSS properties that attach a named timeline to a scroll container or subject element, allowing other elements to reference it via animation-timeline: --my-timeline.
Related
- Core Animation Fundamentals & Browser Mechanics — compositor model, frame budget, and the render pipeline that both APIs build on
- Building Scroll Progress Indicators —
scroll()timeline deep-dive with@scroll-timelinenamed timelines - SPA Page Swap Animations —
startViewTransitionwith client-side routers - Accessibility & Inclusive Motion Standards —
prefers-reduced-motionintegration for every pattern on this page - Cross-Route Element Morphing —
view-transition-namenaming strategy and GPU memory considerations