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.

Timeline Attachment & Scroll Container Mechanics

How the browser decides which box supplies a timeline — the nearest, root and self keywords, what qualifies as a scroll container, axis selection, and named-timeline lookup.


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.

Where the 16 ms frame budget goes A single frame budget of 16.7 milliseconds split into four slices: about 4 ms of JavaScript, 3 ms of style and layout, 2 ms of paint, and 7 ms of compositing and display. A scroll-driven animation touches only the final compositing slice, which is why it survives a busy main thread. one 60fps frame — 16.7 ms from input to pixels JavaScript ~4 ms style + layout ~3 ms paint ~2 ms composite + display ~7 ms the only slice a scroll-driven animation touches Why the split matters A JavaScript scroll handler competes for the first slice, so a long task pushes the whole frame past 16.7 ms and the animation visibly stutters. A CSS timeline is evaluated inside the compositor's own phase, so the same long task delays content but not the animation. 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 view transition lifecycle Five stages in order: startViewTransition captures old bitmaps, the DOM callback runs and the new state is laid out, new bitmaps are captured, the pseudo-element tree animates on the compositor, and finally the tree is torn down and the live DOM is shown. The DOM is frozen only during the middle of the sequence. document.startViewTransition(callback) — what happens, in order 1. capture old bitmap per named element 2. run callback your DOM update, rendering suppressed 3. capture new second bitmap and its geometry 4. animate pseudo-element tree on the compositor 5. tear down live DOM is shown again rendering suppressed — keep this window short Anything expensive inside the callback — a network wait, a large re-render — extends the suppressed window and shows as a frozen page, not as a slow animation. Fetch first, then call startViewTransition with a callback that only commits already-resolved state.

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.

How the two APIs relate to each other

Scroll-driven animations and view transitions are frequently discussed together, and this site treats them as one subject, but they solve different problems and their only real relationship is architectural: both moved interpolation off the main thread and onto the compositor, and both therefore share the same constraints about which properties can participate and what happens when a browser lacks support.

Scroll-driven animations replace a value you used to compute. Before them, connecting a visual property to scroll position meant listening for scroll events, reading geometry, deriving a progress number and writing a style — every frame, on the main thread. animation-timeline replaces that entire loop with a declaration. The API is small because the problem it solves is narrow: it supplies progress. Everything else — what to animate, over what range, with what easing — is ordinary CSS animation machinery that predates it by a decade.

View transitions replace a coordination problem. Animating between two DOM states has never been about a single property; it is about capturing where things were, working out where they ended up, and interpolating between the two while the DOM has already changed. The FLIP technique did this by hand with geometry reads and inverse transforms. The View Transition API does it by taking bitmap snapshots either side of your update and animating generated pseudo-elements between them. The API is small because it hides an enormous amount: the diffing, the pairing, the pseudo-element tree, and the compositing are all internal.

They compose cleanly, and the composition is where the interesting work is. A route change can run a view transition while the destination page’s content reveals on a scroll timeline; a shared element can morph into place and then become the subject of a view() timeline as the reader scrolls past it. Neither API knows about the other, which is exactly why they compose — there is no integration surface to get wrong.

The one place they genuinely interact is the reduced-motion path, and there the difference matters. A scroll-driven animation is declared in CSS, so a media query resets it. A view transition begins with a JavaScript call, so no media query can prevent the snapshot from being taken. A site using both needs both kinds of override, and the second is the one teams forget.

Reading the specifications without reading all of them

The relevant specifications are long, and most of their length concerns cases you will never write. Four sections carry almost all the practical information.

The definition of animation-timeline and the timeline functions. This is where scroll() and view() are given their argument grammars, and where the resolution rules for nearest, root and self are stated precisely. If a timeline is attaching to a scroller you did not expect, the answer is here and nowhere else.

The animation-range value definitions. The named ranges — cover, contain, entry, exit, and the entry-crossing and exit-crossing variants — are defined against precise geometric conditions involving the subject box and the scrollport. Reading the definitions once removes a great deal of trial and error, because the intuitive reading of “entry” and the specified one differ for subjects taller than the scrollport.

The view transition lifecycle. The specification states, in order, exactly when snapshots are taken, when rendering is suppressed, when the callback runs, and when the pseudo-element tree is created and destroyed. Every timing bug in view-transition code is a mismatch with this sequence, and the sequence is a page long.

The pseudo-element tree structure. Which pseudo-elements exist, how they nest, and which of them carry the default animations. This determines which selector to write when you want to change a transition’s behaviour — and explains why a rule targeting the root pseudo-element usually appears to do nothing.

Everything else in those documents is worth knowing eventually and is not worth blocking on. Compatibility data, not the specification, answers the question you most often actually have, which is whether the thing you just read about works in the browsers you support.

What changes when a browser ships one of these features

Feature support arriving is not a single event, and treating it as one causes avoidable churn. In practice a browser ships a timeline function or a transition capability in stages, and each stage changes what your code should do.

The first stage is availability behind a flag. Nothing in your production code should respond to this. It is useful for one purpose only: verifying that your progressive-enhancement path actually enhances, by turning the flag on locally and confirming the page gains the effect rather than changing in some unintended way.

The second stage is stable availability in a single engine. This is when a feature query starts returning true for real users, which means your guarded block starts executing for the first time. The risk here is not that the feature is broken but that the guarded block has never run outside your development machine — a selector typo or a range mistake that has been dormant since it was written now ships. Re-testing the enhanced path when an engine reaches stable is cheap and catches this.

The third stage is availability across engines, and this is the one that changes architecture. While a feature is single-engine, the fallback path is the majority experience and must be good. Once it is broadly available, the fallback becomes an edge case and it becomes reasonable to simplify it — dropping a polyfill, or replacing a carefully built observer fallback with a static end state. Doing this too early degrades the majority experience; doing it never leaves permanent complexity in the codebase for a shrinking audience.

The practical discipline is to keep the guard and revisit the fallback, rather than the reverse. Feature queries cost nothing to leave in place forever. Fallback implementations cost maintenance, so they are the part worth deleting once the support data says they no longer earn it.


Back to home