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.
Named Timelines & timeline-scope
scroll-timeline-name, view-timeline-name, and timeline-scope for hoisting timelines to ancestors and sharing them across components — plus shorthand pitfalls.
View Transition Browser Support Matrix
Same-document vs cross-document support per engine, flags and quirks, @supports guard recipes, and a safe deployment strategy.
Scroll-Driven Media & Gallery Effects
Clip-path image reveals, ambient pans, horizontal galleries, pinned card stacks and scroll-snap interaction — plus the decode and layer costs specific to media.
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.
Choosing a pattern for a given interaction
Most of the patterns in this section can be built several ways, and the decision is rarely about which technique is best in the abstract. It is about which one expresses what the interaction actually means. Four questions resolve almost every case.
Is the effect a function of position, or of an event? Anything the reader can scrub back and forth — a progress bar, a parallax layer, a scene that plays as an element crosses the viewport — is a function of position, and a scroll or view timeline expresses it directly. Anything that happens once and stays happened — a lazy load, an analytics beacon, a count-up that should not run backwards — is a function of an event, and belongs to an observer. Building the first kind out of events produces a stuttering approximation; building the second kind out of a timeline produces an effect that reverses when the reader scrolls up, which almost nobody wants.
Whose progress is it? If every animated element should be at the same point at the same moment, the progress belongs to the scroller and scroll() is the right function. If each element should progress according to its own position, the progress belongs to the subject and view() is right. The tell that you have chosen wrongly is arithmetic: computing per-element delays or offsets by hand is almost always a sign that a view() timeline would have supplied the value for free.
Does the animated element live inside the scroller? If it does, the anonymous forms resolve the scroller automatically and no name is needed. If it does not — a fixed progress bar, a sibling panel, a header outside the scrolling region — the anonymous form cannot find the scroller and a named timeline is not an optimisation but a requirement. Reaching for names before this question is answered adds ceremony; reaching for them after it is answered is unavoidable.
Is the change a state change or a movement? Movement within one state is animation: an element travels, fades or scales while the page stays what it was. A change between two states — a route swap, a list filter, an expand into a detail view — is a transition, and the View Transition API exists because expressing state changes as movement means hand-computing the geometry of both states. When the DOM before and after genuinely differ, reach for the transition; when it does not, an animation is simpler and has broader support.
Answering these four in order takes a minute and settles the implementation. Answering none of them and starting from a technique you have used before is how a site ends up with a scroll handler recomputing what a timeline would have supplied.
Composing patterns without fighting the cascade
A page rarely runs one effect. A typical article template carries a progress bar, a sticky header, reveal animations on several sections, and a route transition on the way in and out. Each is straightforward alone; the problems appear where they meet.
One property, one owner. The single most common composition failure is two systems writing the same property on the same element — a framework transition setting an inline transform while a scroll timeline animates the same property, or two CSS animations both targeting opacity. The cascade resolves it deterministically, which is to say one of them silently loses, and which one depends on origin and declaration order rather than on anything visible in either file. The reliable structure is one property per element per system: give the framework a wrapper to animate and the timeline the child, and neither can overwrite the other.
Reserve layout, animate the rest. Effects that change layout interact with every other effect on the page, because a reflow moves everything. Effects that stay on the compositor interact with nothing. Composing three compositor-safe animations is free; composing one layout-affecting animation with anything else means every other effect is recomputed against a moving box. In practice this makes “reserve the space, animate a transform inside it” the pattern that composes, and “animate the size” the pattern that does not.
Name sparingly, scope tightly. Named timelines and view-transition-name values are both global-ish namespaces. A name declared higher in the tree than it needs to be, or applied to more elements than intended, produces collisions whose symptom is silence — a timeline that resolves to nothing, or a transition that skips. Declaring each name at the lowest element that contains everything needing it keeps collisions structurally impossible rather than merely unlikely.
Give the reduced-motion path the same composition. Overrides are usually written per effect and tested per effect, which leaves the composed reduced-motion page untested. Scroll through it at speed with the preference set: the failure mode is not usually a missing override but a page where three effects were each reduced and the remaining movement still adds up.
What these patterns share
Every pattern in this section is built from the same four decisions, and recognising them makes an unfamiliar effect much faster to implement than treating each as its own recipe.
A progress source. Something must supply a number between zero and one. That is either a scroller’s offset, a subject’s visibility, or — for a transition — the browser’s own interpolation between two captured states. Identifying the source first tells you which API you are in before any code is written.
A range. Almost no effect should run across the entire available progress. A reveal that starts the instant an element’s first pixel enters the viewport and finishes as its last pixel leaves feels sluggish and imprecise; the same reveal mapped to entry 20% through entry 80% feels deliberate. Ranges are where a mechanically correct implementation becomes a good one, and they are the part most often left at the default.
A compositor-safe property to animate. Transform, opacity and filter cover the overwhelming majority of these patterns. When a design appears to require animating something else, the usual resolution is a structural change — reserve the box and animate a transform inside it — rather than accepting the layout cost.
A baseline that stands alone. Every pattern here needs an unguarded default state that is complete without the animation, because that state is what an unsupported engine, a reduced-motion user, and a failed script all receive. Writing that state first is the single habit that separates implementations that degrade gracefully from ones that degrade into blank sections.
Once those four are settled, the actual CSS is short — usually under a dozen declarations for even the elaborate patterns. The length in the guides that follow comes from the failure modes, not from the implementations.
A note on scope
The guides in this section deliberately stop at the browser boundary. They cover what the platform supplies and what it costs, not how any particular framework, component library or content system prefers to express it. That is a choice: the platform surface changes slowly and the tooling around it changes constantly, so a pattern described in terms of animation-timeline, ranges and compositor-safe properties stays correct considerably longer than the same pattern described in terms of a library’s API.
Where framework specifics genuinely change the answer — React’s asynchronous rendering against a synchronous transition callback, Vue’s cached components losing their scroll position, Svelte’s inline-style transitions overwriting a timeline — those get their own guides, because in those cases the framework is not a wrapper over the platform behaviour but a genuine obstacle to it.
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