Zum Inhalt springen

Web-Animationen: GPU-Techniken für konstante 60 fps

Beherrsche GPU-beschleunigte CSS- und JavaScript-Animationen, die konstant 60 fps liefern, ohne Layout Thrashing oder ruckelnde Frames.

5 Min. Lesezeit
Performance-Panel der Browser-DevTools mit flüssigen Animations-Frames bei 60 fps ohne Ruckler

Animationen, die auf der Entwicklungsmaschine flüssig wirken, ruckeln auf echten Geräten oft spürbar. Der Unterschied zwischen einem ausgereiften Produkt und einem ruckeligen liegt darin, zu verstehen, was die Rendering-Engine des Browsers tatsächlich tut, wenn du eine Eigenschaft animierst.

Jede CSS-Eigenschaft fällt in eine von drei Rendering-Kategorien: layout, paint oder composite. Nur Animationen auf der Composite-Ebene erreichen zuverlässig 60 fps, denn sie sind die Einzigen, die die GPU vollständig außerhalb des Hauptthreads verarbeitet.

Die Rendering-Pipeline und warum sie wichtig ist

Wenn du width animierst, berechnet der Browser das Layout für jeden Frame neu. Wenn du background-color animierst, malt er Pixel neu. Wenn du transform oder opacity animierst, setzt er nur vorhandene Ebenen zusammen – ohne Layout oder Paint.

csscss
/* ❌ Animating layout properties: triggers full pipeline */
.card-expand-bad {
  transition: width 300ms ease, height 300ms ease;
}
 
.card-expand-bad:hover {
  width: 400px;   /* Forces layout recalculation */
  height: 300px;  /* Every surrounding element shifts */
}
csscss
/* ✅ Animating composite properties: GPU-only pipeline */
.card-expand-good {
  transition: transform 300ms ease;
  will-change: transform;
}
 
.card-expand-good:hover {
  transform: scale(1.1);  /* GPU handles this without layout */
}

Das visuelle Ergebnis sieht ähnlich aus, aber die Performance-Eigenschaften unterscheiden sich radikal. Die transform-Variante läuft auf dem GPU-Compositor-Thread, völlig unabhängig von der JavaScript-Ausführung im Hauptthread. Selbst wenn dein JavaScript mit der Datenverarbeitung beschäftigt ist, bleibt die Animation flüssig.

Elemente zu Compositor-Ebenen befördern

Damit die GPU-Beschleunigung funktioniert, muss der Browser das animierte Element zu einer eigenen Compositor-Ebene befördern. Bei Animationen von transform und opacity geschieht das automatisch, aber du kannst es auch für Elemente andeuten, die bald animiert werden.

csscss
/* ❌ Overusing will-change: wastes GPU memory */
* {
  will-change: transform;  /* Every element gets its own layer! */
}
 
/* ❌ Static will-change on non-animated elements */
.static-content {
  will-change: transform;  /* Wastes resources */
}
csscss
/* ✅ Targeted will-change only when animation is imminent */
.menu-item {
  transition: transform 200ms ease, opacity 200ms ease;
}
 
.menu-item:hover {
  will-change: transform, opacity;
}
 
.menu-item:active {
  transform: scale(0.95);
  opacity: 0.8;
}
 
/* ✅ Remove will-change after animation completes */
.modal {
  opacity: 0;
  transform: translateY(20px);
  transition: transform 300ms ease, opacity 300ms ease;
}
 
.modal.visible {
  opacity: 1;
  transform: translateY(0);
  will-change: transform, opacity;
}
jsjavascript
// Programmatic will-change management
function prepareAnimation(element) {
  element.style.willChange = "transform, opacity";
 
  element.addEventListener(
    "transitionend",
    () => {
      element.style.willChange = "auto";
    },
    { once: true }
  );
}

Jede Compositor-Ebene verbraucht GPU-Speicher. Auf einem Mobilgerät mit begrenztem VRAM führt das Befördern zu vieler Elemente dazu, dass die GPU beginnt, Ebenen zu verdrängen – was die Performance verschlechtert statt verbessert.

Layout-Animationen durch Transform-Äquivalente ersetzen

Die meisten Animationen, die Layout auslösen, haben transform-basierte Äquivalente, die identisch aussehen, aber dramatisch besser performen.

csscss
/* ❌ Animating top/left: triggers layout every frame */
.tooltip-bad {
  position: absolute;
  top: 0;
  left: 0;
  transition: top 300ms, left 300ms;
}
 
.tooltip-bad.active {
  top: 100px;
  left: 200px;
}
 
/* ✅ Animating transform: composited, no layout */
.tooltip-good {
  position: absolute;
  top: 0;
  left: 0;
  transition: transform 300ms ease;
}
 
.tooltip-good.active {
  transform: translate(200px, 100px);
}
csscss
/* Common replacements for layout properties */
 
/* width/height animation → scale transform */
.expand {
  transform: scale(1);
  transition: transform 200ms;
}
.expand.active {
  transform: scale(1.5);
}
 
/* margin animation → translate transform */
.slide {
  transform: translateX(0);
  transition: transform 300ms;
}
.slide.active {
  transform: translateX(100px);
}
 
/* border-radius animation → clip-path (paint only) */
.morph {
  clip-path: circle(50%);
  transition: clip-path 400ms ease;
}
.morph.active {
  clip-path: circle(100%);
}

Die Eigenschaft transform ist das Arbeitspferd performanter Animationen. translate ersetzt Positionsänderungen, scale ersetzt Größenänderungen und rotate übernimmt Rotationen – alles, ohne das Layout anzufassen.

JavaScript-Animation mit requestAnimationFrame

Wenn CSS-Transitions nicht ausreichen – komplexe Choreografien, physikbasierte Bewegung, Canvas-Animationen –, übernimmt JavaScript. Entscheidend ist die Synchronisation mit dem Bildwiederholzyklus des Browsers.

jsjavascript
// ❌ setTimeout-based animation: inconsistent frame timing
function animateBad(element, targetX) {
  let currentX = 0;
  function step() {
    currentX += 2;
    element.style.transform = `translateX(${currentX}px)`;
    if (currentX < targetX) {
      setTimeout(step, 16); // Not synced to display refresh
    }
  }
  step();
}
jsjavascript
// ✅ requestAnimationFrame with delta time
function animateGood(element, targetX, duration = 300) {
  const startTime = performance.now();
  const startX = 0;
 
  function frame(currentTime) {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);
 
    // Ease-out cubic for natural deceleration
    const eased = 1 - Math.pow(1 - progress, 3);
    const currentX = startX + (targetX - startX) * eased;
 
    element.style.transform = `translateX(${currentX}px)`;
 
    if (progress < 1) {
      requestAnimationFrame(frame);
    }
  }
 
  requestAnimationFrame(frame);
}
jsjavascript
// ✅ Spring physics for natural-feeling animations
function springAnimation(element, target, config = {}) {
  const {
    stiffness = 170,
    damping = 26,
    mass = 1,
    precision = 0.01,
  } = config;
 
  let position = 0;
  let velocity = 0;
 
  function step(timestamp) {
    const dt = 1 / 60; // Fixed timestep for deterministic physics
 
    const springForce = -stiffness * (position - target);
    const dampingForce = -damping * velocity;
    const acceleration = (springForce + dampingForce) / mass;
 
    velocity += acceleration * dt;
    position += velocity * dt;
 
    element.style.transform = `translateX(${position}px)`;
 
    // Stop when close enough and barely moving
    const isSettled =
      Math.abs(position - target) < precision &&
      Math.abs(velocity) < precision;
 
    if (!isSettled) {
      requestAnimationFrame(step);
    }
  }
 
  requestAnimationFrame(step);
}

Federphysik (Spring Physics) erzeugt Animationen, die sich physisch anfühlen, weil sie es sind – die Bewegung folgt echter Federdynamik. Keine Easing-Kurve kann das unterbrechbare, reaktionsschnelle Gefühl von Spring-Animationen nachbilden.

Layout Thrashing bei Animationen vermeiden

Layout Thrashing entsteht, wenn du DOM-Eigenschaften abwechselnd liest und schreibst und den Browser so zwingst, das Layout mehrfach pro Frame neu zu berechnen.

jsjavascript
// ❌ Layout thrashing: read-write-read-write pattern
function animateListBad(items) {
  items.forEach((item) => {
    const height = item.offsetHeight;     // Read (forces layout)
    item.style.height = height * 2 + "px"; // Write (invalidates layout)
    // Next iteration's read triggers another layout!
  });
}
jsjavascript
// ✅ Batch reads then batch writes
function animateListGood(items) {
  // Phase 1: Read all measurements
  const heights = items.map((item) => item.offsetHeight);
 
  // Phase 2: Write all changes (single layout recalculation)
  items.forEach((item, i) => {
    item.style.transform = `scaleY(${heights[i] * 2 / heights[i]})`;
  });
}
 
// ✅ Using the Web Animations API for batched animations
function animateListModern(items) {
  items.forEach((item, index) => {
    item.animate(
      [
        { transform: "translateY(0)", opacity: 1 },
        { transform: "translateY(-20px)", opacity: 0 },
      ],
      {
        duration: 300,
        delay: index * 50,     // Stagger effect
        easing: "ease-out",
        fill: "forwards",
      }
    );
  });
}

Die Web Animations API (WAAPI) ist die moderne Lösung für komplexe JavaScript-Animationen. Sie läuft, wie CSS-Animationen, auf dem Compositor-Thread, jedoch mit der Flexibilität von JavaScript. Die Browser-Unterstützung ist ausgezeichnet, und sie übernimmt das Batching intern.

Animations-Performance messen

Man kann nicht verbessern, was man nicht misst. Chrome DevTools bietet eine Frame-für-Frame-Performance-Analyse für Animationen.

jsjavascript
// Runtime animation performance monitoring
class AnimationMonitor {
  constructor() {
    this.frames = [];
    this.running = false;
  }
 
  start() {
    this.running = true;
    this.frames = [];
    this.lastTimestamp = performance.now();
    this.measure(this.lastTimestamp);
  }
 
  measure(timestamp) {
    if (!this.running) return;
 
    const delta = timestamp - this.lastTimestamp;
    this.frames.push(delta);
    this.lastTimestamp = timestamp;
 
    requestAnimationFrame((ts) => this.measure(ts));
  }
 
  stop() {
    this.running = false;
    return this.getReport();
  }
 
  getReport() {
    const sorted = [...this.frames].sort((a, b) => a - b);
    const p50 = sorted[Math.floor(sorted.length * 0.5)];
    const p95 = sorted[Math.floor(sorted.length * 0.95)];
    const p99 = sorted[Math.floor(sorted.length * 0.99)];
    const jankFrames = this.frames.filter((d) => d > 16.67).length;
 
    return {
      totalFrames: this.frames.length,
      droppedFrames: jankFrames,
      jankPercentage: ((jankFrames / this.frames.length) * 100).toFixed(1),
      p50FrameTime: p50.toFixed(2),
      p95FrameTime: p95.toFixed(2),
      p99FrameTime: p99.toFixed(2),
    };
  }
}

Ein gesunder Animationsbericht zeigt weniger als 5 % geruckelte Frames und p95-Frame-Zeiten unter 16,67 ms (das Budget für 60 fps). Überschreitet dein p95-Wert das, werden Nutzer mit Geräten der Mittelklasse sichtbares Ruckeln bemerken.

Die wichtigsten Erkenntnisse

Flüssige Animationen entstehen dadurch, dass man die Rendering-Pipeline des Browsers respektiert, statt gegen sie anzukämpfen. Animiere wann immer möglich transform und opacity – sie sind die einzigen Eigenschaften, die Layout und Paint vollständig überspringen. Setze will-change sparsam ein und nur dann, wenn eine Animation unmittelbar bevorsteht. Bündle DOM-Lese- und Schreibvorgänge, um Layout Thrashing zu vermeiden. Und am wichtigsten: Miss auf echten Geräten – die Leistung deiner Entwicklungsmaschine ist nicht repräsentativ für die Erfahrung deiner Nutzer.

Die Web Animations API ist die Zukunft komplexer Web-Animationen und verbindet die Performance des Compositor-Threads mit der Kontrolle auf JavaScript-Ebene. Für physikbasierte Bewegung liefern Spring-Animationen ein Gefühl, das keine vordefinierte Easing-Kurve erreichen kann. Das Ziel ist nicht, Animation zu vermeiden – sondern die richtigen Eigenschaften auf der richtigen Ebene der Rendering-Pipeline zu animieren.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX