CSS Animations vs JavaScript Libraries: When to Use Each

The choice between native CSS scroll-driven animations and JavaScript libraries such as GSAP or Framer Motion is not a matter of preference — it is determined by thread allocation, browser support, and the specific type of motion you need. This page gives concrete, measurable criteria for the decision, grounded in how the rendering pipeline separates compositor work from main-thread recalculation.

When to Use This Approach

Use native CSS animations when any of the following apply:

  • JavaScript scroll listeners are measurably raising Total Blocking Time (TBT) or Interaction to Next Paint (INP) above 200 ms — compositor-delegated timelines eliminate that overhead entirely.
  • You are animating transform, opacity, or filter properties in response to scroll position — these are the properties the compositor thread can interpolate without main-thread involvement.
  • You need route or page transition effects and browser support for the View Transitions API is acceptable for your user base.
  • The motion is deterministic — a fixed mapping from scroll progress to a CSS value, with no velocity-dependent or physics-based branching.

Use a JavaScript library instead when:

  • You need spring physics, drag inertia, or momentum — CSS cubic-bezier() and linear() easing cannot model these.
  • Animations depend on live state from a framework store (Redux, Pinia, Svelte stores) and must change mid-scroll based on that state.
  • You need sequenced stagger timings that are computed dynamically at runtime.
  • You must target browsers that have not shipped animation-timeline (Firefox shipped in version 110 for @scroll-timeline and 132 for the full spec; check polyfill options for Safari and older engines if you need broader coverage).

Implementation

Step 1 — Replace imperative scroll listeners with a CSS timeline

The most direct migration is from a scroll event listener that writes transform values to an animation-timeline: scroll() declaration. The JavaScript pattern forces a style recalculation on every scroll event; the CSS pattern runs entirely off the main thread.

/* Remove the JS listener and the inline style mutation entirely */
.parallax-element {
  animation: scroll-parallax linear both;
  animation-timeline: scroll(root block);
}

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

The scroll(root block) argument maps the document’s block-axis scroll progress to animation progress. No JavaScript runs during scroll — the browser compositor reads the scroll offset and interpolates the keyframe directly.

Step 2 — Replace JavaScript page transition libraries with View Transitions

Libraries like Barba.js and custom FLIP implementations call getBoundingClientRect() to measure geometry, then apply inline style mutations to animate. This generates Recalculate Style and Layout work in the performance timeline and can cause garbage collection pauses on list-heavy pages.

The View Transitions API captures element snapshots off the main thread and composites between them on the GPU. The main thread sees zero layout work during the animation playback window.

/* Assign a unique transition name to each morphing element */
.hero-image {
  view-transition-name: hero-image;
}
async function navigate(url) {
  if (!document.startViewTransition) {
    // Fallback for browsers without View Transitions support
    window.location.href = url;
    return;
  }
  await document.startViewTransition(async () => {
    await fetchAndSwapContent(url);
  });
}

view-transition-name values must be unique across the entire DOM at the moment startViewTransition is called. Duplicate names cause a silent fallback to a root crossfade — no console warning in any browser engine.

Step 3 — Use CSS custom properties as the bridge inside frameworks

React, Vue, and Svelte reconcile virtual DOM updates during re-renders. If a component re-renders while a CSS scroll-driven animation is active, inline style mutations from the framework can reset the element’s animation-timeline binding, detaching the animation mid-scroll.

The compositor-safe pattern is to update a single CSS custom property per scroll event and let CSS compute the transform:

// React: write one CSS variable, not the transform directly
function ScrollDrivenCard({ containerRef }) {
  useEffect(() => {
    const el = containerRef.current;
    const onScroll = () => {
      const progress = window.scrollY / document.body.scrollHeight;
      el.style.setProperty('--scroll-progress', progress);
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, [containerRef]);
}
.scroll-driven-card {
  transform: translateY(calc(var(--scroll-progress, 0) * -100px));
  will-change: transform;
}

Framework re-renders do not overwrite CSS custom properties set via element.style.setProperty. The custom property value survives reconciliation, so the visual position is preserved without detaching any animation-timeline binding on the CSS side.

This pattern is the correct fallback for cases where animation-timeline cannot be used directly because of framework state coupling — it keeps paint and compositing in CSS while JavaScript only writes a single variable.

Verification

After migrating a scroll animation from JavaScript to CSS, confirm compositor isolation in three steps:

1. Performance panel — no scripting during scroll

Open DevTools → Performance → record a 3–5 second rapid scroll. The main thread flame chart should show no Scripting blocks or Layout events during scroll. If you see requestAnimationFrame callbacks firing, a JavaScript scroll listener is still active.

2. Layers panel — composite, not paint

Open DevTools → Layers. Animated elements should show a Composite reason. If they show Paint or Layout, the animated property is not compositor-safe (only transform, opacity, and filter guarantee compositor delegation). Adding will-change: transform explicitly promotes the layer before the first scroll frame, eliminating the one-frame promotion delay.

3. Animations panel — timeline binding confirmed

Open DevTools → Animations. Active scroll-driven animations appear with a scroll-progress scrubber. Drag the scrubber to confirm the keyframe range maps correctly to the scroll container’s extent. If the animation does not appear here, the animation-timeline property is not resolving — check for a misspelled scroll container selector or a contain property that is blocking timeline inheritance.

4. Core Web Vitals — INP and TBT drop

Run Lighthouse (mobile, throttled) before and after migration. A working compositor migration typically reduces TBT by 40–70% on mid-tier Android hardware. INP improvements are visible in Chrome DevTools’ performance insights under “Interactions”.

Edge Cases and Gotchas

Over-promotion budget exhaustion. Browser tabs have a practical limit of roughly 100–200 active GPU compositor layers. Adding will-change: transform to many elements simultaneously forces software rasterization once the budget is exceeded — this causes visible jank at high scroll velocities rather than eliminating it. Use the Layers panel to audit layer count before shipping. Apply will-change only to elements that are actively animating.

overflow-anchor interrupting CSS timeline progression. When content above the viewport changes height, overflow-anchor: auto (the default) forces a synchronous layout recalculation to preserve scroll position. This layout work can interrupt compositor-thread CSS timeline progression, causing a single-frame jump. Disable it on scroll-animated containers:

.scroll-container {
  overflow-anchor: none;
}

Duplicate view-transition-name values. As noted above, duplicate names across the DOM at capture time cause a silent full-page crossfade. The failure is invisible to the user but destroys the morphing effect. Use unique suffixes tied to item IDs when rendering lists: view-transition-name: card-${item.id}.

contain: strict blocking timeline inheritance. The contain property creates a new scroll-timeline scope. An element with contain: strict or contain: layout cannot inherit a scroll-timeline declared on an ancestor outside the containment boundary. This is the most common reason animation-timeline: scroll() silently does nothing — remove contain from intermediate ancestors, or use a named scroll-timeline on the scroll container itself.

will-change and snapshot capture conflict. Setting will-change: transform on an element also creates a new stacking context, which affects view-transition-name snapshot capture. If the snapshot bounding box appears incorrect in the View Transitions debug overlay, temporarily remove will-change from the element and add it back via JavaScript only for the duration of the scroll animation, not during the transition.

Browser-Specific Notes

Chrome (115+). Full animation-timeline: scroll() and animation-timeline: view() support. The Animations panel in DevTools displays scroll-driven animations with a draggable progress scrubber. document.startViewTransition is available; cross-document View Transitions require an opt-in via @view-transition { navigation: auto; }.

Safari (17.4+). animation-timeline: scroll() and view() shipped in Safari 17.4. The Web Inspector Timeline panel does not yet expose a scroll-progress scrubber — verify binding via the getAnimations() API in the console: document.querySelector('.el').getAnimations() should return an object with a timeline property of type ScrollTimeline. View Transitions shipped in Safari 18.

Firefox (132+). The full animation-timeline spec shipped in Firefox 132. Earlier versions (110–131) supported the older @scroll-timeline at-rule syntax, which is now deprecated. If you are targeting Firefox 110–131, use the scroll-timeline polyfill which also covers that gap. View Transitions are not yet shipped in Firefox as of mid-2026; use a @supports guard:

@supports (animation-timeline: scroll()) {
  .animated {
    animation: fade-in linear both;
    animation-timeline: scroll(root);
  }
}

Decision matrix

Scenario Use CSS Use JavaScript
Scroll-linked transform / opacity Yes — compositor-isolated Only if physics needed
Page / route transitions Yes — View Transitions API FLIP fallback for unsupported browsers
Spring physics / drag inertia No Yes — GSAP / Framer Motion
Framework store–dependent animation Partial — CSS variable bridge Yes for complex state coupling
Staggered sequential timelines Limited — animation-delay only Yes — GSAP timeline API
Safari < 17.4 Polyfill or JS fallback Yes

CSS vs JavaScript Animation Decision Flow A flowchart showing the decision path for choosing CSS scroll-driven animations over JavaScript libraries based on compositor safety, physics needs, and browser support. Scroll-linked animation needed? Physics / drag / dynamic stagger? Yes Use JS lib (GSAP / Framer) No Browser support OK? (Chrome 115+, Safari 17.4+, FF 132+) No Polyfill or JS fallback Yes Framework state coupling? (React / Vue / Svelte re-render risk) Yes CSS var bridge No Use CSS animation-timeline Compositor-isolated, zero JS overhead

Frequently Asked Questions

Does will-change: transform guarantee compositor isolation for scroll-driven animations?

Not on its own. will-change: transform promotes the element to its own GPU layer before the first frame, eliminating the one-frame promotion delay, but compositor isolation for the animation itself is determined by the animated property. Only transform, opacity, and filter run on the compositor thread — if you animate width, height, background-color, or any other property via @keyframes, the animation forces a main-thread paint regardless of will-change.

Can I use GSAP ScrollTrigger alongside CSS animation-timeline?

Yes, but they must not animate the same property on the same element simultaneously. GSAP ScrollTrigger writes inline styles; animation-timeline applies via the CSS cascade. Inline styles have higher specificity and will override animation-timeline keyframe values on any property they share. Keep them on separate elements or separate properties.

Why does my animation-timeline do nothing inside a React component?

The most common cause is a re-render writing an inline style prop to the element after the animation-timeline CSS rule is applied. React’s style prop maps to inline styles, which override all CSS declarations including animation-timeline. Remove the style prop from the animated element and use a CSS class for all animation-related properties. If you must write inline styles, use the CSS custom property bridge pattern from Step 3 above.


Related

Up: The Rendering Pipeline for Scroll Animations