Core Animation Fundamentals & Browser Mechanics

CSS Scroll-Driven Animations (CSS Level 2 Scroll-Driven Animations spec, shipped Chrome 115 / Safari 17.4) and the View Transitions API (shipped Chrome 111 / Safari 18) both delegate interpolation off the main thread and onto the compositor. That single architectural decision determines what can animate at full frame rate, what triggers layout recalculation, and what fails silently when a browser lacks support. This section establishes the foundational mechanics — compositor model, rendering pipeline, timeline architecture, and state continuity — that underpin every implementation pattern on this site.

Topics in this section

Understanding the CSS Scroll-Timeline API

scroll() and view() timeline functions, animation-range syntax, named scroll timelines, and how the browser wires progress to offset without JavaScript.

The Rendering Pipeline for Scroll Animations

How compositor-thread isolation, layer promotion, and paint containment let scroll-driven animations hit the 16 ms frame budget on complex layouts.

How @view-transition Works Under the Hood

DOM snapshotting, the ::view-transition-old / ::view-transition-new pseudo-element lifecycle, and navigation-level transitions with the @view-transition at-rule.

Browser Support & Progressive Enhancement

Shipped dates, flag status per engine, @supports guard patterns, and layered fallback architecture that keeps baseline experiences intact.

Fallback Strategies for Legacy Browsers

IntersectionObserver replacements, polyfill architecture, and requestAnimationFrame patterns that stay within the frame budget when native APIs are absent.


Core concept: declarative timeline architecture

Traditional scroll-linked animations attach scroll event listeners that fire on the main thread, compete with layout recalculation, and require manual requestAnimationFrame throttling to avoid dropping frames. CSS Scroll-Driven Animations replace that pattern entirely: animation progress is bound to scroll offset through two timeline functions evaluated inside the compositor’s own phase.

/* scroll() — progress tracks position within a scroll container */
.hero__parallax {
  animation: parallax-move linear;
  animation-timeline: scroll(root);   /* 0% = scroll start, 100% = scroll end */
  animation-range: 0% 100%;
  will-change: transform;
}

@keyframes parallax-move {
  from { transform: translateY(0); }
  to   { transform: translateY(-200px); }
}

/* view() — progress tracks intersection with the scroll container */
.card {
  animation: fade-up both linear;
  animation-timeline: view();         /* 0% = element enters viewport, 100% = exits */
  animation-range: entry 0% entry 40%;
}

@keyframes fade-up {
  from { opacity: 0; transform: translateY(2rem); }
  to   { opacity: 1; transform: translateY(0); }
}

Both scroll() and view() run on the compositor thread when the animated properties are transform, opacity, or filter. No JavaScript executes per frame; no style recalculation is triggered. The browser interpolates directly from the pre-computed GPU texture.

The animation-range property narrows which portion of the timeline drives the animation. The entry, exit, cover, and contain keywords correspond to precise intersection thresholds defined in the spec:

Keyword Start End
cover Element’s leading edge reaches the scroll container’s end Element’s trailing edge leaves the container’s start
contain Element’s trailing edge reaches the container’s end Element’s leading edge reaches the container’s start
entry Element’s leading edge enters the container Element’s trailing edge enters the container
exit Element’s leading edge leaves the container Element’s trailing edge leaves the container

Named scroll timelines extend this pattern to elements that are not direct scroll parents:

/* Declare a named timeline on the scroller */
.feed {
  overflow-y: scroll;
  scroll-timeline-name: --feed-scroll;
  scroll-timeline-axis: block;
}

/* Attach to it from any descendant or sibling */
.feed__header {
  animation: shrink-header linear;
  animation-timeline: --feed-scroll;
  animation-range: 0px 120px;
}

@keyframes shrink-header {
  to { height: 3rem; }
}

Rendering pipeline implications

Each frame at 60 Hz must complete style resolution, layout, paint rasterization, and compositing within 16.6 ms. The browser executes these phases in strict order; any step that forces recalculation of an earlier phase compounds the cost.

Browser Rendering Pipeline Five sequential phases of the browser rendering pipeline. CSS scroll-driven animations enter at the Composite phase, bypassing Style, Layout, and Paint entirely. Style Cascade / vars Layout Box model / reflow Paint Rasterise pixels Composite GPU layers scroll-driven enters here Display Screen output CSS scroll-driven animations bypass these phases

Forced synchronous layout — reading a layout property (offsetHeight, getBoundingClientRect) immediately after a DOM write — is the primary source of scroll jank in JavaScript-based implementations. CSS timelines are evaluated during the compositor’s own phase, not during JavaScript execution, so they never trigger this pattern.

The contain property signals to the browser that a subtree’s layout and paint are self-contained, enabling aggressive layer isolation:

.scroll-animated-card {
  contain: layout paint style;    /* isolate this subtree's rendering */
  content-visibility: auto;       /* skip off-screen paint entirely */
  will-change: transform, opacity; /* promote to own GPU layer */
}

will-change: transform promotes an element to an independent compositor layer before any animation begins. This has a memory cost: each promoted layer consumes GPU memory for its texture. Promote only elements that genuinely animate on the compositor; do not apply will-change globally as a blanket performance fix.

For a thorough analysis of layer promotion, contain, and the full frame budget breakdown, see The Rendering Pipeline for Scroll Animations.

Timing functions in a scroll context

For scroll-driven animations, animation progress maps to scroll position rather than elapsed time. cubic-bezier() curves still apply — they shape how rapidly the animated value changes as a function of scroll progress — but there is no wall-clock duration to set. The effective duration is the scroll distance covered by animation-range.

The linear() function (Chrome 113+, Safari 17.2+) allows piecewise easing with arbitrary control points, which is particularly useful for spring simulations that must be authored in CSS:

.scroll-progress-bar {
  animation: fill-progress linear;
  animation-timeline: scroll(root);
  /* Piecewise spring approximation */
  animation-timing-function: linear(
    0 0%, 0.15 10%, 0.35 30%, 0.55 50%, 0.75 70%, 0.9 90%, 1 100%
  );
  transform-origin: left center;
}

@keyframes fill-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

State continuity with view transitions

The View Transitions API solves a different problem: how do you animate between two distinct DOM states — whether different routes in a SPA, different card layouts, or before/after a data mutation — without writing manual FLIP calculations?

The browser executes the transition in five steps:

  1. Snapshot old state. document.startViewTransition() captures a visual snapshot of the current DOM, including all view-transition-name regions.
  2. Pause rendering. The page appears frozen while the callback runs.
  3. Apply mutation. Your callback (synchronous or async) updates the DOM — route change, content swap, whatever the transition requires.
  4. Snapshot new state. The browser captures the post-mutation layout.
  5. Animate. ::view-transition-old(<name>) and ::view-transition-new(<name>) pseudo-elements are layered over the page and interpolated on the compositor.
async function navigateTo(path) {
  // Guard: API is not available in Firefox or older Safari
  if (!document.startViewTransition) {
    window.location.href = path;
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateRoute(path);       // swap DOM content; can be async
  });

  // transition.ready resolves when pseudo-elements exist — safe to drive via WAAPI
  await transition.ready;
}
/* Tag specific elements for matched cross-fade */
.product-card {
  view-transition-name: product-card;  /* must be unique per page */
}

/* Override the default cross-fade on the matched pair */
::view-transition-old(product-card) {
  animation: slide-out-left 300ms ease-in both;
}
::view-transition-new(product-card) {
  animation: slide-in-right 300ms ease-out both;
}

@keyframes slide-out-left {
  to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in-right {
  from { transform: translateX(100%); opacity: 0; }
}

Navigation-level transitions (page-to-page in a multi-page app) use the @view-transition at-rule, which instructs the browser to trigger the mechanism automatically during navigation without any JavaScript:

@view-transition {
  navigation: auto;
}

The full pseudo-element lifecycle, snapshot timing, and cross-document restrictions are covered in How @view-transition Works Under the Hood.


Browser support matrix

Feature Chrome Safari Firefox Edge
animation-timeline: scroll() 115 (Aug 2023) 17.4 (Mar 2024) Flag only 115 (Aug 2023)
animation-timeline: view() 115 (Aug 2023) 17.4 (Mar 2024) Flag only 115 (Aug 2023)
Named scroll-timeline 115 (Aug 2023) 17.4 (Mar 2024) Flag only 115 (Aug 2023)
animation-range keywords 115 (Aug 2023) 17.4 (Mar 2024) Flag only 115 (Aug 2023)
linear() timing function 113 (Apr 2023) 17.2 (Dec 2023) 112 (Apr 2023) 113 (Apr 2023)
View Transitions (same-document) 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-class 125 (May 2024) 18.2 (Dec 2024) Unsupported 125 (May 2024)

Global usage coverage for scroll-driven animations is approximately 75–78% across Chrome + Safari stable (as of mid-2026). Firefox’s flag-only stance means around 4% of global traffic arrives without any native support. Full feature detection patterns and polyfill options are in Browser Support & Progressive Enhancement.


Progressive enhancement strategy

The correct guard for scroll-driven animations is @supports (animation-timeline: scroll()). Do not use the older @supports (animation-name: x) pattern — it matches in Firefox (which supports WAAPI) but does not indicate scroll-timeline support.

/* Baseline: element is always visible, never animates */
.reveal-card {
  opacity: 1;
  transform: none;
}

/* Enhanced: animate only when the API is fully supported */
@supports (animation-timeline: scroll()) {
  .reveal-card {
    opacity: 0;
    transform: translateY(1.5rem);
    animation: reveal-card both linear;
    animation-timeline: view();
    animation-range: entry 10% entry 50%;
  }
}

@keyframes reveal-card {
  to { opacity: 1; transform: none; }
}
/* View Transitions guard */
@supports (view-transition-name: none) {
  .hero-image {
    view-transition-name: hero-image;
  }
}

Always declare the fallback state first, without any @supports guard. The default state must be a complete, accessible experience that works in every browser. The @supports block layers in the enhancement. For prefers-reduced-motion, reset the timeline entirely — not just the keyframes — because an active timeline can still apply animation-fill-mode effects even when keyframes are empty:

@media (prefers-reduced-motion: reduce) {
  .reveal-card {
    animation-timeline: auto;  /* detach from scroll progress */
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Fallback Strategies for Legacy Browsers covers the full polyfill chain and IntersectionObserver-based approximations for browsers where @supports returns false.


Framework integration notes

React

React’s reconciler applies inline styles during commit, which runs synchronously after paint. If a component sets style={{ animationTimeline: 'scroll()' }} as an inline style, React will overwrite it on every re-render. Declare animation-timeline in a CSS class instead, and apply the class to the element — never the inline style prop.

Hydration resets are a related problem: during SSR hydration, React re-attaches event listeners and may trigger a re-render that clears any scroll state. Use useLayoutEffect (not useEffect) to apply class additions that must be synchronous with the paint cycle, and ensure the element receives will-change: transform server-side to avoid a compositor layer promotion flash.

// Wrong: React will overwrite this on re-renders
<div style={{ animationTimeline: 'scroll()' }}>…</div>

// Correct: CSS class is stable across reconciler passes
<div className="scroll-reveal-card">…</div>

Vue

Vue 3’s <Transition> component applies enter/leave classes using its own lifecycle hooks, which can conflict with view-transition-name if both target the same element. Use one or the other per element: Vue <Transition> for component-internal enter/leave; view-transition-name for cross-route morphing. Vue’s v-bind:style has the same inline-style collision risk as React’s style prop — use scoped CSS or :deep() selectors for animation-timeline declarations.

Svelte

Svelte’s transition: directive and animate: directive both generate inline styles at runtime. If a Svelte animate:flip directive runs on the same element as a CSS animation-timeline, the Svelte runtime will overwrite the animation shorthand property, clearing the timeline binding. Apply animation-timeline to a wrapper element that Svelte’s directive does not target.


{#each items as item (item.id)}
{item.label}
{/each}

Debugging workflow

Chrome DevTools — Performance panel

  1. Open DevTools → Performance tab.
  2. Start recording, scroll through the animated section, stop recording.
  3. In the flame chart, look for Recalculate Style and Layout events during scroll. If scroll-driven animations are running correctly on the compositor, you will see neither.
  4. A Recalculate Style event during scroll indicates that a JavaScript scroll listener is still active, or that a non-compositor property is being animated.

Chrome DevTools — Layers panel

  1. Open DevTools → More tools → Layers.
  2. Select a will-change: transform element. The panel should show it on its own compositor layer, distinct from the document’s root layer.
  3. If the element is not promoted, check that will-change is applied before the animation starts (not toggled on in a keyframe). The paint cost column shows memory usage per layer — a high cost indicates too-large or too-numerous promoted layers.

Chrome DevTools — Animations panel

  1. Open DevTools → More tools → Animations.
  2. Scroll the page. Scroll-driven animations appear in the panel as timeline bars. The panel lets you scrub progress and inspect animation-range breakpoints.
  3. A “No animations recorded” result when you expect scroll-driven animations usually means the @supports guard did not match, or a typo in the timeline function name prevented it from being parsed.

Common error messages

animation-timeline: --my-timeline with no matching scroll-timeline-name: --my-timeline in the ancestor tree resolves to none silently. Verify the named timeline is declared on a scrolling ancestor (not a sibling or descendant) and that the custom property name matches exactly, including the -- prefix.

view-transition-name values must be unique within a single document snapshot. A duplicate name causes the entire startViewTransition() call to abort and the promise to reject. Use console.log(document.getAnimations()) after transition.ready to confirm the pseudo-element animations are running.


Key concepts index

scroll() timeline function : Binds animation progress to the scroll position of a named or root scroll container. Syntax: scroll(<scroller> <axis>). Default scroller is nearest, default axis is block.

view() timeline function : Binds animation progress to an element’s intersection with its scroll container. Syntax: view(<axis> <view-timeline-inset>). Progress runs from the moment the element enters the scroll port to the moment it exits.

animation-range : Shorthand for animation-range-start and animation-range-end. Accepts a percentage of the timeline, or a keyword offset such as entry 0% entry 40%. Narrows the active portion of the timeline that drives the animation.

scroll-timeline-name : Declares a named scroll timeline on a scroll container. Combined with scroll-timeline-axis, it makes the timeline addressable by name from any element in the same scope.

view-transition-name : Assigns an identity to a DOM element so the View Transitions API can track it between old and new DOM snapshots. Must be unique per snapshot; can be dynamically assigned via JavaScript before calling startViewTransition.

::view-transition-old(<name>) / ::view-transition-new(<name>) : Pseudo-elements generated by the browser during a view transition. old represents the captured screenshot of the pre-mutation state; new represents the live post-mutation rendering. Both are animatable with standard CSS.

will-change : Instructs the browser to promote an element to an independent compositor layer before it starts animating. Values: transform, opacity, filter. Costs GPU memory; use only on elements that actively animate.

contain: layout paint style : CSS containment that tells the browser a subtree’s layout, paint, and style cannot affect siblings. Enables aggressive layer isolation and accelerates content-visibility: auto rendering.

animation-fill-mode : Controls whether an animation applies its values before (backwards) or after (forwards) its active range within the timeline. When animation-timeline is a scroll or view timeline, both is the most common value.

@view-transition : At-rule that opts a page into cross-document (navigation) view transitions without JavaScript. Syntax: @view-transition { navigation: auto; }. Requires both the outgoing and incoming pages to declare it.


Back to home