Animation Performance and Accessibility

โ–ถ Try It Yourself

Motion that runs at 60 frames per second feels polished; motion that drops frames feels broken. And for roughly 35% of users who have vestibular disorders or motion sensitivity, excessive animation can cause physical discomfort or make a website unusable. In this lesson you will learn how browsers render animations, which properties are safe to animate, how to use will-change correctly, and how to respect user motion preferences with prefers-reduced-motion.

The Browser Rendering Pipeline

Stage What It Does Cost
Style Computes which CSS rules apply to each element Low
Layout Calculates position and size of every element Very High โ€” triggers re-layout of entire tree
Paint Fills in pixels โ€” colours, borders, shadows High โ€” repaints affected layers
Composite Assembles layers and draws to screen (GPU) Very Low โ€” done entirely on the GPU thread

Properties by Rendering Cost

Property Triggers Performance
transform Composite only ✅ Excellent โ€” GPU thread, no layout, no paint
opacity Composite only ✅ Excellent โ€” same as transform
filter Paint + Composite 🔴 Good โ€” paint cost, but no layout
background-color, color Paint only 🔴 Acceptable โ€” triggers repaint, not layout
box-shadow Paint 🔴 Expensive at large blur radii
width, height, padding, margin Layout + Paint + Composite ❌ Avoid in animations โ€” reflows entire page
top, left, right, bottom Layout + Paint + Composite ❌ Avoid โ€” use transform: translate() instead

prefers-reduced-motion Media Query

Value User Preference CSS Action
no-preference User has no reduced-motion setting enabled Full animations as designed
reduce User has enabled “Reduce Motion” in OS accessibility settings Remove or simplify all animations
Note: will-change: transform tells the browser to promote the element to its own compositor layer before the animation starts, eliminating the frame-drop that occurs when the browser has to promote it mid-animation. However, every layer uses GPU memory โ€” use will-change only on elements that will animate soon, and remove it after the animation completes using JavaScript or by overriding in a class.
Tip: The safest prefers-reduced-motion pattern is a reset at the top of your stylesheet: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }. This disables all animations globally for users who need it, without you having to hunt down every individual animation rule.
Warning: Overusing will-change is worse than not using it at all. Promoting every element to its own layer exhausts GPU memory and can make the page slower overall. The rule: use will-change only on elements you know will animate, and only when you have profiler evidence of jank. Never apply will-change: transform to every element as a blanket optimisation.

Basic Example

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
  <title>Animation Performance & Accessibility</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    html { font-size: 100%; }
    body { font-family: 'Inter', system-ui, sans-serif; background: #f8fafc; padding: 40px 32px; color: #1e293b; }
    h3   { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 40px 0 16px; }
    .row { display: flex; flex-wrap: wrap; gap: 24px; align-items: center; }

    /* โ”€โ”€ Safe animation: transform + opacity only โ”€โ”€ */
    @keyframes slideUp {
      from { opacity: 0; transform: translateY(24px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    .safe-card {
      background: white;
      border: 1px solid #e2e8f0;
      border-radius: 14px;
      padding: 20px;
      width: 180px;
      animation: slideUp 0.5s ease-out both;
    }
    .safe-card:nth-child(2) { animation-delay: 0.1s; }
    .safe-card:nth-child(3) { animation-delay: 0.2s; }

    /* โ”€โ”€ will-change used correctly โ”€โ”€ */
    .will-change-btn {
      padding: 12px 24px;
      background: #4f46e5;
      color: white;
      border: none;
      border-radius: 10px;
      font-size: 0.875rem;
      font-weight: 600;
      cursor: pointer;
      font-family: inherit;
      transition: transform 0.2s ease, box-shadow 0.2s ease;
    }
    /* will-change applied only on hover โ€” not statically */
    .will-change-btn:hover {
      will-change: transform;
      transform: translateY(-3px);
      box-shadow: 0 8px 20px rgba(79,70,229,0.3);
    }

    /* โ”€โ”€ Reduced motion: respect user preference โ”€โ”€ */

    /* Default: full animation */
    @keyframes spinCog {
      to { transform: rotate(360deg); }
    }
    .cog {
      display: inline-block;
      font-size: 2rem;
      animation: spinCog 3s linear infinite;
    }

    /* Slow-pulsing shimmer โ€” also used for loading state */
    @keyframes loadPulse {
      0%, 100% { opacity: 1; }
      50%       { opacity: 0.4; }
    }
    .loading-text {
      font-size: 0.875rem; color: #64748b;
      animation: loadPulse 1.5s ease-in-out infinite;
    }

    /* โ”€โ”€ Global reduced motion reset โ”€โ”€ */
    @media (prefers-reduced-motion: reduce) {
      /* Nuclear option โ€” disable everything */
      *, *::before, *::after {
        animation-duration:        0.01ms !important;
        animation-iteration-count: 1      !important;
        transition-duration:       0.01ms !important;
        scroll-behavior:           auto   !important;
      }
    }

    /* โ”€โ”€ Motion-safe: only add animation when motion is ok โ”€โ”€ */
    .motion-safe-card {
      background: white;
      border: 1px solid #e2e8f0;
      border-radius: 14px;
      padding: 20px;
      width: 200px;
      opacity: 0;
    }
    @media (prefers-reduced-motion: no-preference) {
      .motion-safe-card {
        animation: slideUp 0.5s ease-out 0.2s both;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      .motion-safe-card {
        opacity: 1;  /* show immediately without animation */
      }
    }

    /* โ”€โ”€ Performance comparison labels โ”€โ”€ */
    .perf-ok  { background: #dcfce7; color: #166534; border-radius: 6px; padding: 4px 10px; font-size: 0.75rem; font-weight: 700; }
    .perf-bad { background: #fee2e2; color: #991b1b; border-radius: 6px; padding: 4px 10px; font-size: 0.75rem; font-weight: 700; }
    .perf-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
    .perf-table th { text-align: left; padding: 8px 12px; background: #f1f5f9; color: #475569; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
    .perf-table td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; }
  </style>
</head>
<body>

  <h3>Safe Animations โ€” transform + opacity only</h3>
  <div class="row">
    <div class="safe-card"><p style="font-weight:700;color:#0f172a;margin-bottom:4px">Card A</p><p style="font-size:0.8rem;color:#64748b">transform + opacity only</p></div>
    <div class="safe-card"><p style="font-weight:700;color:#0f172a;margin-bottom:4px">Card B</p><p style="font-size:0.8rem;color:#64748b">GPU composited</p></div>
    <div class="safe-card"><p style="font-weight:700;color:#0f172a;margin-bottom:4px">Card C</p><p style="font-size:0.8rem;color:#64748b">60fps guaranteed</p></div>
  </div>

  <h3>Reduce Motion โ€” spinning cog respects OS setting</h3>
  <div class="row">
    <span class="cog">⚙</span>
    <p class="loading-text">Loading your data…</p>
    <p style="font-size:0.8rem;color:#64748b;max-width:300px">Enable "Reduce Motion" in your OS accessibility settings โ€” both animations stop immediately due to the global reset at the bottom of this stylesheet.</p>
  </div>

  <h3>motion-safe Card โ€” appears only when motion is allowed</h3>
  <div class="row">
    <div class="motion-safe-card">
      <p style="font-weight:700;color:#0f172a;margin-bottom:4px">Motion-safe</p>
      <p style="font-size:0.8rem;color:#64748b">Animates in when motion is OK; appears instantly when reduced motion is set.</p>
    </div>
  </div>

  <h3>Rendering Cost Summary</h3>
  <table class="perf-table">
    <thead><tr><th>Property</th><th>Triggers</th><th>Verdict</th></tr></thead>
    <tbody>
      <tr><td><code>transform</code>, <code>opacity</code></td><td>Composite only</td><td><span class="perf-ok">✓ Excellent</span></td></tr>
      <tr><td><code>background-color</code></td><td>Paint</td><td><span class="perf-ok">✓ Acceptable</span></td></tr>
      <tr><td><code>box-shadow</code></td><td>Paint</td><td><span class="perf-ok">✓ Acceptable</span></td></tr>
      <tr><td><code>width</code>, <code>height</code></td><td>Layout + Paint + Composite</td><td><span class="perf-bad">✕ Avoid</span></td></tr>
      <tr><td><code>top</code>, <code>left</code></td><td>Layout + Paint + Composite</td><td><span class="perf-bad">✕ Avoid</span></td></tr>
    </tbody>
  </table>

</body>
</html>

How It Works

Step 1 โ€” Composite-Only Properties Bypass the Main Thread

When you animate only transform and opacity, the browser keeps all the work on the GPU compositor thread. The main JavaScript thread can be completely blocked โ€” running expensive code โ€” and the animation continues at 60fps because it never needs the main thread’s results.

Step 2 โ€” The Global Reduced Motion Reset

The @media (prefers-reduced-motion: reduce) block sets all animation and transition durations to 0.01ms โ€” effectively instant. The !important overrides any inline styles or third-party library animations. 0.01ms instead of 0s ensures JavaScript animation event listeners (animationend) still fire.

Step 3 โ€” motion-safe vs motion-reduce Pattern

Instead of the nuclear reset, a more surgical approach wraps animation declarations in @media (prefers-reduced-motion: no-preference) โ€” only adding animations when the user has not requested reduced motion. Separately, the reduced-motion block sets opacity: 1 so elements that would have faded in are immediately visible.

Step 4 โ€” will-change on :hover Only

Applying will-change: transform only on :hover means the browser promotes the element to a GPU layer a split second before the transition starts. This is slightly better than setting it statically โ€” it avoids the memory cost of the layer during the long periods when the element is not being hovered.

Step 5 โ€” Profiling in DevTools

In Chrome DevTools, the Rendering panel has “Paint flashing” and “Layer borders” toggles. Paint flashing shows green overlays whenever the browser repaints โ€” useful for catching unnecessary paints during animations. The Performance recorder shows the full rendering pipeline per frame, making it easy to spot layout thrash in animations.

Real-World Example: Accessible Modal Entrance

/* accessible-modal.css */

/* Backdrop */
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.6);
  backdrop-filter: blur(4px);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}
.modal-backdrop.open {
  opacity: 1;
  pointer-events: auto;
}

/* Modal panel */
.modal-panel {
  background: white;
  border-radius: 20px;
  padding: 32px;
  max-width: 480px;
  width: calc(100% - 48px);
  box-shadow: 0 24px 64px rgba(0,0,0,0.2);
  transform: scale(0.92) translateY(16px);
  opacity: 0;
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
              opacity 0.25s ease;
}
.modal-backdrop.open .modal-panel {
  transform: scale(1) translateY(0);
  opacity: 1;
}

/* Exit โ€” CSS class toggled by JS */
.modal-backdrop.closing .modal-panel {
  transform: scale(0.95) translateY(8px);
  opacity: 0;
  transition: transform 0.2s ease-in, opacity 0.2s ease-in;
}

/* โ”€โ”€ Respect reduced motion โ”€โ”€ */
@media (prefers-reduced-motion: reduce) {
  .modal-backdrop,
  .modal-panel {
    transition: opacity 0.15s ease;
  }
  .modal-panel {
    transform: none; /* skip scale and translate animation */
  }
  .modal-backdrop.open .modal-panel { transform: none; }
}

Common Mistakes

Mistake 1 โ€” Applying will-change globally

โŒ Wrong โ€” every element gets its own GPU layer, exhausting VRAM:

* { will-change: transform; } /* catastrophic โ€” creates thousands of layers */

โœ… Correct โ€” apply only to the specific elements that will animate:

.animated-card { will-change: transform; } /* only the cards that animate */

Mistake 2 โ€” Ignoring prefers-reduced-motion entirely

โŒ Wrong โ€” vestibular disorder sufferers get all animations regardless:

/* No @media (prefers-reduced-motion) in the stylesheet at all */

โœ… Correct โ€” add the global reset as a safety net at minimum:

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

Mistake 3 โ€” Animating width/height for accordions

โŒ Wrong โ€” animating height from 0 to auto forces layout thrash every frame:

.accordion-body { height: 0; overflow: hidden; transition: height 0.3s ease; }
.open .accordion-body { height: auto; } /* auto is not animatable */

โœ… Correct โ€” animate max-height with a known maximum, or use scaleY:

.accordion-body { max-height: 0; overflow: hidden; transition: max-height 0.4s ease; }
.open .accordion-body { max-height: 600px; }

▶ Try It Yourself

Quick Reference

Topic Rule Notes
Safe properties transform, opacity Composite only โ€” GPU thread
Avoid animating width, height, top, left, margin Triggers layout recalculation every frame
Reduced motion reset @media (prefers-reduced-motion: reduce) Set duration to 0.01ms โ€” keeps JS events firing
motion-safe pattern Wrap animations in @media (prefers-reduced-motion: no-preference) More surgical than the global reset
will-change will-change: transform Apply sparingly โ€” only to elements about to animate
Profiling DevTools Rendering โ†’ Paint flashing Visualise repaints and layout thrash

🧠 Test Yourself

Which pair of CSS properties can be animated at 60fps without triggering layout or paint โ€” only compositing?





โ–ถ Try It Yourself