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.


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.

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

  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.


← Home