Skip to content

Animation Systems: CSS, WAAPI, and Scroll-Driven

advanced17 min read

Three Ways to Animate on the Web

The web has three animation systems, each with different strengths:

  1. CSS Animations and Transitions — declarative, simple, GPU-optimized
  2. Web Animations API (WAAPI) — JavaScript control over CSS animations, full playback API
  3. Scroll-driven Animations — animations linked to scroll position instead of time

Most developers learn CSS animations, reach for a JS library when they need control, and never learn WAAPI or scroll-driven animations. That's a mistake. WAAPI gives you programmatic control without sacrificing GPU performance. Scroll-driven animations eliminate scroll event listeners entirely. Together, they cover every animation need without a third-party library.

Mental Model

Think of the three animation systems as modes of transportation. CSS animations are a train — they follow a fixed track (keyframes), run on a schedule (duration), and are extremely efficient because the route is predetermined. WAAPI is a car — you can steer (pause, reverse, change speed), but you're still on roads the browser paved (compositor-friendly properties). Scroll-driven animations are a cable car — movement is tied to the cable (scroll position), not a clock.

CSS Animations: The Foundation

Transitions

Transitions animate between two states:

.card {
  transform: scale(1);
  opacity: 1;
  transition: transform 200ms ease-out, opacity 200ms ease-out;
}

.card:hover {
  transform: scale(1.02);
  opacity: 0.95;
}

Keyframe Animations

Keyframes define multi-step animations:

@keyframes fadeSlideIn {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.element {
  animation: fadeSlideIn 300ms ease-out forwards;
}

The Performance Rule

Not all CSS properties animate equally. Some trigger layout recalculation (expensive), some trigger paint (moderate), and some run entirely on the compositor thread (free):

Property TypeExamplesPerformance
Compositor-only (best)transform, opacityRuns on GPU compositor thread. Zero main thread work. Smooth 60fps.
Paint (moderate)background-color, box-shadow, border-radiusTriggers repaint but no layout. Moderate cost.
Layout (worst)width, height, top, left, margin, padding, font-sizeTriggers full layout recalculation, paint, and composite. Janky at 60fps.
Quiz
Which CSS properties can animate at 60fps without blocking the main thread?

will-change: Use With Caution

.animated-element {
  will-change: transform;
}

will-change tells the browser to prepare for an animation — typically by promoting the element to its own compositor layer. This uses GPU memory. Apply it only to elements that will actually animate, and remove it when the animation is done.

Common Trap

Don't blanket-apply will-change: transform to everything. Each promoted layer consumes GPU memory (the size of the element as a bitmap). On a mobile device with limited GPU memory, promoting too many elements causes the browser to start de-promoting layers — making performance worse, not better. Profile with Chrome DevTools Layers panel.

Web Animations API (WAAPI)

WAAPI exposes CSS animation capabilities through JavaScript, giving you programmatic control:

const animation = element.animate(
  [
    { transform: 'translateX(0)', opacity: 1 },
    { transform: 'translateX(300px)', opacity: 0 },
  ],
  {
    duration: 500,
    easing: 'ease-in-out',
    fill: 'forwards',
  }
);

This creates the same animation as @keyframes + CSS, but you get back an Animation object with full playback control.

Playback Control

animation.pause();
animation.play();
animation.reverse();
animation.cancel();
animation.finish();

animation.playbackRate = 2;
animation.playbackRate = -1;

animation.currentTime = 250;

await animation.finished;
console.log('Animation complete');

animation.finished is a Promise that resolves when the animation completes — no more animationend event listeners.

Composite Modes

When multiple animations target the same property, composite modes determine how they combine:

element.animate(
  [{ transform: 'translateX(100px)' }],
  { duration: 1000, composite: 'add' }
);

element.animate(
  [{ transform: 'rotate(45deg)' }],
  { duration: 1000, composite: 'add' }
);
  • replace (default): the animation's value replaces the underlying value
  • add: values are added together (translateX + rotate)
  • accumulate: like add, but for list values (multiple transforms)
Quiz
What does WAAPI give you that CSS animations do not?

Replacing Animation Libraries

Most things people use GSAP or Framer Motion for can be done with WAAPI:

async function staggerReveal(elements) {
  const animations = elements.map((el, i) =>
    el.animate(
      [
        { opacity: 0, transform: 'translateY(20px)' },
        { opacity: 1, transform: 'translateY(0)' },
      ],
      {
        duration: 400,
        delay: i * 50,
        easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
        fill: 'forwards',
      }
    )
  );

  await Promise.all(animations.map((a) => a.finished));
}

Staggered reveal animation with no dependencies. 12 lines. Runs on the compositor.

Scroll-Driven Animations

Scroll-driven animations bind animation progress to scroll position instead of time. No scroll event listeners, no IntersectionObserver hacks, no JavaScript.

As of 2026, scroll-driven animations work in Chrome, Edge, Safari 26+, with Firefox support coming.

ScrollTimeline

Tracks the scroll position of a container:

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

.progress-bar {
  animation: progress linear;
  animation-timeline: scroll();
  transform-origin: left;
}

That's a progress bar that fills as you scroll down the page. Zero JavaScript.

.parallax-bg {
  animation: parallax linear;
  animation-timeline: scroll();
}

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

Parallax effect. Zero JavaScript. Runs on the compositor.

ViewTimeline

Tracks an element's visibility in the viewport:

.fade-in {
  animation: fadeIn linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(50px); }
  to { opacity: 1; transform: translateY(0); }
}

As the element scrolls into view, it fades in and slides up. animation-range: entry 0% entry 100% means the animation runs from when the element starts entering the viewport to when it's fully entered.

animation-range

The animation-range property controls which portion of the scroll timeline maps to the animation:

  • entry — while the element enters the scrollport
  • exit — while the element exits the scrollport
  • contain — while the element is fully contained in the scrollport
  • cover — from first entry to last exit
.reveal {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 30%;
}

JavaScript API

WAAPI supports scroll timelines too:

const scrollTimeline = new ScrollTimeline({
  source: document.scrollingElement,
  axis: 'block',
});

element.animate(
  [
    { transform: 'rotate(0deg)' },
    { transform: 'rotate(360deg)' },
  ],
  { timeline: scrollTimeline }
);
const viewTimeline = new ViewTimeline({
  subject: element,
  axis: 'block',
});

element.animate(
  [
    { opacity: 0 },
    { opacity: 1 },
  ],
  {
    timeline: viewTimeline,
    rangeStart: 'entry 0%',
    rangeEnd: 'entry 100%',
  }
);
Quiz
How are scroll-driven animations fundamentally different from scroll event listeners?

prefers-reduced-motion: Respecting User Preferences

Some users experience motion sickness, vestibular disorders, or simply prefer less animation. The prefers-reduced-motion media query lets you respect their preference:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Or more granularly:

.card {
  transition: transform 200ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: opacity 200ms ease-out;
  }
}

In JavaScript:

const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;

element.animate(keyframes, {
  duration: prefersReduced ? 0 : 500,
  easing: 'ease-out',
});
This is not optional

Respecting prefers-reduced-motion is a WCAG 2.1 AA requirement (Success Criterion 2.3.3). Users who enable reduced motion may have vestibular disorders that cause nausea or seizures from excessive animation. Always provide a reduced-motion alternative.

Progressive Enhancement for Scroll-Driven Animations

Since Firefox support is still pending, use feature detection:

@supports (animation-timeline: scroll()) {
  .progress-bar {
    animation: progress linear;
    animation-timeline: scroll();
  }
}

In JavaScript:

if (CSS.supports('animation-timeline', 'scroll()')) {
  // Use scroll-driven animation
} else {
  // Fall back to IntersectionObserver or scroll listener
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
      }
    });
  });
}

When to Use Which

NeedBest Tool
Simple hover/focus state changesCSS transitions
Looping decorative animationsCSS @keyframes
Interactive animations (pause, reverse, seek)WAAPI
Staggered entrance animationsWAAPI with delay
Scroll-linked progress indicatorsScroll-driven (CSS)
Element reveal on scrollScroll-driven ViewTimeline
Parallax effectsScroll-driven ScrollTimeline
Complex orchestrated sequencesWAAPI with animation.finished chaining
Physics-based animationsThird-party library (Motion, GSAP)
What developers doWhat they should do
Animating width, height, top, or left for motion effects
width/height/top/left trigger layout recalculation on the main thread. transform and opacity run on the compositor thread — a separate thread that continues even when the main thread is blocked. This is the single biggest animation performance win.
Use transform (translate, scale, rotate) and opacity for smooth 60fps animations
Using scroll event listeners for scroll-linked animations
Scroll listeners fire on the main thread. If the main thread is busy (JS execution, layout), your scroll animation janks. Scroll-driven animations are declarative and run on the compositor — smooth regardless of main thread load.
Use CSS scroll-driven animations or WAAPI with ScrollTimeline for compositor-driven scroll animations
Applying will-change to many elements permanently
Each will-change: transform element gets its own compositor layer, consuming GPU memory. Too many layers cause the browser to start thrashing — compositing itself becomes the bottleneck. Use it surgically, not as a global performance hack.
Apply will-change only to elements about to animate, and remove it after the animation completes
Ignoring prefers-reduced-motion
This is both a WCAG 2.1 AA requirement and a real accessibility need. Users with vestibular disorders can experience nausea and disorientation from excessive motion.
Always provide a reduced-motion alternative — shorter durations, opacity-only transitions, or no animation
Key Rules
  1. 1Animate only transform and opacity for guaranteed 60fps — they run on the compositor thread
  2. 2WAAPI gives you JavaScript control (pause, reverse, playbackRate, await .finished) with CSS animation performance
  3. 3Scroll-driven animations (ScrollTimeline, ViewTimeline) replace scroll listeners with compositor-driven scroll effects
  4. 4Always respect prefers-reduced-motion — it is a WCAG requirement, not a nice-to-have
  5. 5Use @supports (animation-timeline: scroll()) for progressive enhancement of scroll-driven animations