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.

Clip-path image reveals, ambient pans, horizontal galleries, pinned card stacks and scroll-snap interaction — plus the decode and layer costs specific to media.


CSS Scroll-Driven & View Transitions rendering pipeline Two parallel pipelines: Scroll-Driven Animations feeds from scroll position through animation-timeline to the compositor. View Transitions captures DOM snapshots, transitions via pseudo-elements, and composites the result. Both bypass main-thread layout for compositable properties. Scroll-Driven Animations Scroll / viewport offset user scrolls → position value (0%–100%) scroll() / view() animation-timeline resolution Maps offset → animation progress (0→1) animation-range clips range @keyframes interpolation transform / opacity / filter values computed Compositor thread No layout/paint — 60 fps on GPU View Transitions API startViewTransition(updateCallback) Snapshot old DOM state (GPU bitmap) await updateCallback() Snapshot new DOM state ::view-transition-old / ::view-transition-new view-transition-name matching ::view-transition-group interpolation Geometry + opacity crossfade between bitmaps Compositor thread GPU-composited crossfade / morph Both APIs bypass main-thread style/layout/paint for compositable properties transform · opacity · filter → compositor only | padding / width / color → forces recalc

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 a 0%–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.

Where each pattern family does its work Five pattern families placed against the pipeline. Progress indicators and parallax touch only compositing. Sticky headers add a layout-position dependency. Shared-element morphs add a snapshot capture on the main thread. Framework page swaps add application code before the capture even begins. the further right a family reaches, the more main-thread work it owns composite + layout read + snapshot + app code Progress indicators scale on GPU Parallax layers translate Sticky headers sticky position is resolved during layout Shared-element morphs two bitmap captures bracket the DOM update Framework page swaps router state, re-render, then capture — your code sets the ceiling

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

Nothing is animating — triage order Four checks in order, cheapest first. Is the timeline attached at all — does the computed value show a timeline? Is the scroller actually scrollable — a container with no overflow produces no progress. Is the range non-empty? And is another rule overriding the animation later in the cascade? 1. Is a timeline attached? Computed → animation-timeline shows auto: the declaration lost the cascade, or the property name is misspelled 2. Can the scroller scroll? scrollHeight > clientHeight no overflow means no progress — the most common cause on a short test page 3. Is the range non-empty? animation-range a start past the end collapses the animation to a single frozen frame 4. Is something overriding it? an inline style or a later layer wins silently
  1. 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.
  2. Check the Layers panel (Ctrl+Shift+I → Rendering → Layer Borders). Elements with will-change: transform or active animation-timeline should 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.
  3. Common error: animation stays frozen. Cause: animation-timeline is set but the element is not inside the referenced scroll container. Fix: verify the element’s scroll ancestor matches the scroll() argument (root for the document scroller, or pass the element reference for a named timeline).
  4. Common error: animation-range has no effect. Cause: animation-range requires animation-timeline to be set first; CSS cascade order matters. Fix: place animation-range after animation-timeline in the rule, or use shorthand animation property carefully (timeline is the last value).

View Transitions

  1. 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 the startViewTransition callback to artificially slow the transition to 10 s, making the pseudo-elements inspectable.
  2. Inspect pseudo-elements. During an active transition, open Elements panel. The html element will contain ::view-transition > ::view-transition-group(root) > ::view-transition-image-pair(root). Named elements appear as additional ::view-transition-group siblings.
  3. Common error: morph does not animate. Cause: view-transition-name value on old DOM and new DOM do not match exactly (case-sensitive). Or the named element was display: none at capture time — hidden elements are not snapshotted.
  4. 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.
  5. Common error: InvalidStateError on startViewTransition. Cause: a previous transition is still running. Fix: store the transition object and call t.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.


← Home