Web Animations: Less Is More

Why most web animations do more harm than good — and how to correctly implement the few that actually help.

CSSPerformanceUXAnimation

Why animations often do more harm than good

Animations are tempting. They make a site feel alive, demonstrate craft, and can — in the right dose — genuinely improve the user experience. The problem is the dose.

The most common mistakes:

Animations as distraction. When every element flies in on scroll, every button pulses on hover, and every transition takes 400ms, the animation competes with the content. Users see the animation, not the product.

Animations without meaning. A good animation communicates something: causality (this button triggered something), hierarchy (this modal comes from this element), or state (this component is loading). An animation that just "looks nice" contributes nothing to UX.

Animations at a performance cost. This is the technical damage: animations that trigger layout calculations or paint operations eat main thread time and cause jank — visible stuttering that makes the site feel broken.

The first step is asking: what does this animation communicate? If the answer is "nothing," cut it.

The browser rendering pipeline: Layout, Paint, Composite

To understand which animations are expensive, you need to understand the rendering pipeline. The browser goes through up to three phases for every frame:

Layout (Reflow) — The browser calculates the position and size of every element. Expensive, because a change to one element can require recalculating all dependent elements. Triggered by changes to width, height, margin, padding, top, left, font-size, and many more.

Paint — The browser draws pixels for elements. Less expensive than layout, but not free. Triggered by changes to color, background-color, border, box-shadow, text-decoration, and other visual properties.

Composite — The browser combines already-rendered layers. This is the only phase that runs on the GPU and doesn't block the main thread. Only transform and opacity trigger exclusively Composite.

Layout → Paint → Composite  (expensive: everything recalculated)
         Paint → Composite  (medium: repaint needed)
                 Composite  (cheap: just move existing layers)

For smooth 60fps animations, you need to stay in the Composite phase.

The golden rule: only animate transform and opacity

This is the most important practical consequence of the rendering pipeline:

/* WRONG: triggers Layout */
.element {
  animation: slide-in 300ms ease;
}
@keyframes slide-in {
  from { left: -100px; }
  to   { left: 0; }
}

/* RIGHT: Composite only */
.element {
  animation: slide-in 300ms ease;
}
@keyframes slide-in {
  from { transform: translateX(-100px); }
  to   { transform: translateX(0); }
}

Both animations look identical. But the first recalculates layout on every frame. The second moves an already-rendered layer on the GPU.

The same applies to fade animations:

/* WRONG: triggers Paint */
@keyframes fade-in {
  from { visibility: hidden; }
  to   { visibility: visible; }
}

/* RIGHT: Composite only */
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

And for size changes:

/* WRONG: triggers Layout */
@keyframes grow {
  from { width: 0; height: 0; }
  to   { width: 200px; height: 200px; }
}

/* RIGHT: scale is Composite */
@keyframes grow {
  from { transform: scale(0); }
  to   { transform: scale(1); }
}

The rule has exceptions: sometimes you can't avoid width/height. In that case, you should promote the animated element to its own compositor layer (more on that with will-change).

will-change — when it helps and when it hurts

will-change is a hint to the browser that a property will change soon:

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

What this does: the browser creates a dedicated compositor layer for the element in advance. When the animation starts, the layer is already ready — no setup overhead.

When will-change makes sense:

  • Complex animations with many elements
  • Animations triggered by user interaction (hover, scroll)
  • Elements that move frequently and regularly

When will-change hurts:

  • On too many elements simultaneously — every layer costs GPU memory
  • Permanently on static elements — the layer is created and never used
  • As a fix for layout thrashing — that's the wrong optimization
/* WRONG: on all elements */
* { will-change: transform; }

/* RIGHT: targeted and temporary */
.card:hover { will-change: transform; }
.card { transition: transform 200ms ease; }

Even better: add and remove will-change dynamically via JavaScript:

const card = document.querySelector('.card')
card.addEventListener('mouseenter', () => {
  card.style.willChange = 'transform'
})
card.addEventListener('mouseleave', () => {
  card.style.willChange = 'auto'
})

This way the layer is only created during the interaction and immediately released afterwards.

prefers-reduced-motion: not a nice-to-have, but mandatory

prefers-reduced-motion is a CSS media query that reports whether the user has enabled "Reduce Motion" in their operating system. This is not a small minority: estimates suggest 10–25% of people with vestibular or neurological conditions use this setting.

/* Base: animation for everyone */
.element {
  transition: transform 300ms ease;
}

/* Reduced: no animation for affected users */
@media (prefers-reduced-motion: reduce) {
  .element {
    transition: none;
  }
}

Or the better approach — opt-in instead of opt-out:

/* Default: no animation */
.element {
  /* static */
}

/* Only animate when animations are OK */
@media (prefers-reduced-motion: no-preference) {
  .element {
    transition: transform 300ms ease;
  }
}

In Vue/Nuxt:

<script setup>
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
</script>

<template>
  <Transition :name="prefersReducedMotion ? '' : 'fade'">
    <div v-if="visible">Content</div>
  </Transition>
</template>

Disabling animations doesn't mean making the page worse. It means making it usable for everyone.

Scroll animations: IntersectionObserver instead of scroll events

A common pattern: revealing elements while scrolling. The naive implementation listens to scroll events:

// WRONG: scroll event is expensive
window.addEventListener('scroll', () => {
  elements.forEach(el => {
    if (el.getBoundingClientRect().top < window.innerHeight) {
      el.classList.add('visible')
    }
  })
})

getBoundingClientRect() forces the browser into a synchronous layout reflow on every scroll event. On mobile devices, this means visible stuttering.

IntersectionObserver is the correct solution:

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible')
        observer.unobserve(entry.target) // only once
      }
    })
  },
  { threshold: 0.1 } // 10% of element visible
)

document.querySelectorAll('.animate-on-scroll').forEach(el => {
  observer.observe(el)
})

IntersectionObserver runs off the main thread and only notifies when visibility status changes — no polling, no layout thrashing.

CSS @keyframes for the actual animation:

.animate-on-scroll {
  opacity: 0;
  transform: translateY(20px);
}

.animate-on-scroll.visible {
  animation: reveal 400ms ease forwards;
}

@keyframes reveal {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .animate-on-scroll.visible {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Concrete checklist: when an animation is justified

Before every animation, ask these questions:

  1. Does it communicate something? State change, causality, orientation, feedback — or just decoration?
  2. Does it follow the 60fps rule? Only transform and opacity, or is Layout/Paint involved?
  3. Is the duration appropriate? Micro-interactions: 100–200ms. State transitions: 200–300ms. Page transitions: 400ms maximum. Anything over 500ms feels slow.
  4. Is it interruptible? Users shouldn't have to wait for an animation to finish before they can interact.
  5. Does it respect prefers-reduced-motion? If not, it's not done.
  6. Does it run on a low-end device? The real test device is not the MacBook Pro.

If an animation passes all six points, it's justified. If not, simplifying or cutting it is the better decision.

Motion libraries (Motion for Vue / GSAP) — with restraint

Libraries like Motion for Vue (formerly Framer Motion for Vue) or GSAP can simplify animations — but they change nothing about the underlying principles.

A bad animation with GSAP is still a bad animation. A layout-thrashing animation with Motion for Vue is still expensive.

What these libraries actually provide:

  • Declarative syntax<Motion :animate="{ x: 100 }" /> instead of @keyframes
  • Spring physics — more natural, physical movement instead of linear curves
  • Gesture integration — Drag, Hover, Tap as first-class features
  • Orchestrationstagger, sequence, AnimatePresence for complex choreography
<script setup>
import { Motion } from 'motion/vue'
</script>

<template>
  <!-- Only transform + opacity → GPU-accelerated -->
  <Motion
    :initial="{ opacity: 0, y: 20 }"
    :animate="{ opacity: 1, y: 0 }"
    :transition="{ duration: 0.3, ease: 'easeOut' }"
  >
    <div>Content</div>
  </Motion>
</template>

Using them is justified when the animation complexity warrants the dependency cost. For simple transitions and hover effects, vanilla CSS is the better, lighter choice. For complex, physically-feeling interactions and choreographies, a library is worth it.

GSAP is the choice for the most complex animations — ScrollTrigger, SVG morphing, timeline orchestration. But GSAP is also 70KB+ and should only be loaded when you genuinely need that complexity.

The rule of thumb: CSS for everything simple. Motion for Vue for more complex interactive animations. GSAP for the extreme end.