Partikel-Hintergrund ohne Bibliothek
Three.js ist 600 KB schwer, tsParticles bringt weitere Abhängigkeiten mit. Für einen animierten Sternenhimmel oder Partikel-Hintergrund braucht man beides nicht. Die native Canvas API mit requestAnimationFrame und Alpine.js x-data liefern dasselbe visuelle Ergebnis in unter 100 Zeilen – ohne Build-Step, ohne npm, ohne Bundle-Overhead.
Inhaltsverzeichnis
- 1. Warum Canvas statt CSS-Animationen oder Bibliotheken?
- 2. Alpine.js und Canvas: x-data als Animations-Controller
- 3. Das Partikel-System: Initialisierung und Grundeigenschaften
- 4. Der Animation-Loop mit requestAnimationFrame
- 5. Partikel-Physik: Bewegung, Randbehandlung und Lebenszeit
- 6. Maus-Interaktion: Anziehen und Abstoßen
- 7. Performance-Optimierung: OffscreenCanvas und devicePixelRatio
- 8. Responsives Verhalten: ResizeObserver und Canvas-Skalierung
- 9. Canvas vs. CSS vs. SVG: Wann welcher Ansatz?
- 10. Zusammenfassung
- 11. FAQ
1. Warum Canvas statt CSS-Animationen oder Bibliotheken?
CSS-Animationen sind für einfache, deklarative Übergänge ideal – Farbe, Opacity, Transform für einzelne Elemente. Sobald jedoch hunderte Partikel mit individuellen Positionen, Geschwindigkeiten und Interaktionen animiert werden sollen, stößt CSS an seine Grenzen. Jeder Partikel als eigenes DOM-Element würde den Browser mit Reflow-Berechnungen überlasten. Die Canvas API umgeht dieses Problem komplett: statt DOM-Elementen zeichnet sie direkt auf ein Bitmap, das GPU-beschleunigt dargestellt wird. Hunderte Partikel laufen bei 60 FPS, ohne auch nur eine einzige DOM-Manipulation auszulösen.
Bibliotheken wie Three.js oder tsParticles abstrahieren diese Canvas-Operationen und bieten vorgefertigte Partikel-Systeme an. Für komplexe 3D-Szenen ist Three.js unverzichtbar. Für einen einfachen Starfield- oder Partikel-Hintergrund auf einer Webseite sind diese Abstraktionen jedoch überdimensioniert: Three.js bringt 580 KB minifiziert mit, tsParticles weitere 100–200 KB je nach Konfiguration. Mit nativer Canvas API und Alpine.js x-data braucht man nichts zu laden – der Browser kann alles, was für diesen Anwendungsfall nötig ist.
Alpine.js übernimmt in diesem Setup die Rolle des Lifecycle-Managers: init() startet die Animation, destroy() (via x-on:unload) stoppt die Schleife und gibt Ressourcen frei. Die Canvas-Referenz erhält man über this.$refs.canvas innerhalb der Alpine-Komponente. Das macht das gesamte Setup wartbar und verhindert Memory-Leaks durch vergessene requestAnimationFrame-Schleifen bei Page-Navigation in Single-Page-Setups.
2. Alpine.js und Canvas: x-data als Animations-Controller
Das x-data-Objekt des Starfield-Widgets hält alle State-Variablen, die die Animation steuern: das Array der Partikel, die Canvas-Referenz, den 2D-Rendering-Context, die aktuelle Frame-ID des requestAnimationFrame-Aufrufs und die Mausposition. Diese Variablen sind nicht reaktiv im Alpine-Sinne – Alpine muss nicht bei jedem Frame das DOM aktualisieren. Der Canvas-Inhalt wird direkt durch die JavaScript-Zeichenoperationen aktualisiert. x-data dient hier als strukturierter Container für den Animations-State, nicht als reaktiver Data-Store.
Die init()-Methode, die Alpine automatisch nach dem DOM-Ready aufruft, initialisiert den Canvas: setzt Breite und Höhe auf die Elternelement-Dimensionen, holt den 2D-Context mit getContext('2d'), erzeugt die initiale Partikel-Population und startet die Animations-Schleife. Ein ResizeObserver auf dem Container-Element stellt sicher, dass der Canvas bei Fenstergröße-Änderungen neu skaliert wird. Alpine's $nextTick stellt sicher, dass der Canvas-DOM-Knoten beim Aufruf von init() bereits im DOM ist und über this.$refs.canvas erreichbar ist.
// Alpine.js Starfield component — Canvas as animation target
function starfield(options = {}) {
return {
particles: [],
ctx: null,
animFrameId: null,
mouse: { x: -9999, y: -9999 },
config: {
count: options.count ?? 150,
speed: options.speed ?? 0.5,
maxRadius: options.maxRadius ?? 2.5,
connectDistance: options.connectDistance ?? 100,
mouseRepel: options.mouseRepel ?? 80,
},
init() {
const canvas = this.$refs.canvas;
this.ctx = canvas.getContext('2d');
this.resize();
this.spawnParticles();
this.loop();
const ro = new ResizeObserver(() => this.resize());
ro.observe(canvas.parentElement);
canvas.addEventListener('mousemove', (e) => {
const r = canvas.getBoundingClientRect();
this.mouse.x = (e.clientX - r.left) * (canvas.width / r.width);
this.mouse.y = (e.clientY - r.top) * (canvas.height / r.height);
});
canvas.addEventListener('mouseleave', () => {
this.mouse.x = -9999; this.mouse.y = -9999;
});
},
resize() {
const canvas = this.$refs.canvas;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
this.ctx.scale(dpr, dpr);
},
destroy() {
cancelAnimationFrame(this.animFrameId);
}
};
}
3. Das Partikel-System: Initialisierung und Grundeigenschaften
Jedes Partikel-Objekt enthält die Eigenschaften, die seine Bewegung und Darstellung beschreiben: Position (x, y), Geschwindigkeit (vx, vy), Radius (r), Opacity (alpha) und optional eine individuelle Farbe. Diese Objekte werden als einfache JavaScript-Literale gespeichert – kein class-Overhead, kein Prototype-Lookup. Die spawnParticles()-Methode erzeugt das Array mit count Partikeln, verteilt sie zufällig über die Canvas-Fläche und weist jeder Geschwindigkeit und Radius zufällig zu, normiert auf die konfigurierte Maximalgeschwindigkeit.
Der Radius der Partikel folgt einer quadratischen Verteilung: die meisten Sterne sind klein, wenige sind groß. Das erzeugt eine realistischere Tiefenwirkung. Die Opacity variiert zwischen 0.3 und 1.0 und blinkt leicht pro Frame (alpha += Math.sin(frame * 0.02 + phase) * 0.005). Dieses subtile Flackern der Sterne entsteht ohne zusätzliche Animationslogik durch eine einfache Sinus-Funktion mit einem individuellen Phasen-Offset pro Partikel.
4. Der Animation-Loop mit requestAnimationFrame
requestAnimationFrame ist die browser-native API für flüssige Animationen: sie synchronisiert den Zeichenaufruf mit dem Monitor-Refresh (typischerweise 60 Hz oder 120 Hz) und pausiert automatisch bei unsichtbaren Tabs. Das spart CPU und Akku, wenn die Seite nicht sichtbar ist – ein Feature, das bei setInterval-basierten Animationsschleifen manuell über die Page Visibility API nachgebaut werden müsste. Die loop()-Methode ruft sich selbst über requestAnimationFrame rekursiv auf und speichert die zurückgegebene Frame-ID in this.animFrameId.
Der Canvas wird zu Beginn jedes Frames nicht vollständig geleert, sondern mit einem halb-transparenten Rechteck übermalt: ctx.fillStyle = 'rgba(15, 23, 42, 0.15)' erzeugt einen Motion-Blur-Effekt, der die Bewegungsspur der Partikel sichtbar macht. Je höher der Alpha-Wert dieses Übermaler-Rechtecks, desto kürzer die Spur. Ein Wert von 1.0 entspricht einem vollständigen Löschen des Canvas. Dieser Trick ist einfacher und performanter als das Speichern von Partikel-Positionen der letzten N Frames.
// Animation loop with trail effect and particle update
loop() {
const { ctx } = this;
const W = this.$refs.canvas.width / (window.devicePixelRatio || 1);
const H = this.$refs.canvas.height / (window.devicePixelRatio || 1);
// Partial clear — creates motion trail
ctx.fillStyle = 'rgba(15, 23, 42, 0.18)';
ctx.fillRect(0, 0, W, H);
this.frame = (this.frame || 0) + 1;
for (const p of this.particles) {
// Move
p.x += p.vx;
p.y += p.vy;
// Wrap around edges
if (p.x < 0) p.x = W;
if (p.x > W) p.x = 0;
if (p.y < 0) p.y = H;
if (p.y > H) p.y = 0;
// Twinkle via sine wave
p.alpha = 0.5 + 0.5 * Math.sin(this.frame * 0.015 + p.phase);
// Draw star
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(94, 234, 212, ${p.alpha})`;
ctx.fill();
}
this.drawConnections(W, H);
this.animFrameId = requestAnimationFrame(() => this.loop());
},
5. Partikel-Physik: Bewegung, Randbehandlung und Lebenszeit
Die einfachste Partikel-Physik ist linear: Position += Geschwindigkeit pro Frame. Ohne weitere Kräfte bewegen sich die Sterne in gleichförmiger Geraden über den Canvas. Die Randbehandlung entscheidet über das visuelle Verhalten: beim Wrap-Around (Tunneling) erscheint ein Partikel, der die rechte Kante verlässt, links wieder. Das erzeugt eine nahtlose, endlose Bewegung wie in einem alten Weltraum-Arcade-Spiel. Als Alternative gibt es Reflexion (vx *= -1 beim Randkontakt) oder Neuerscheinen an zufälliger Position an der gegenüberliegenden Kante.
Für einen Sternenhimmel-Effekt ist Wrap-Around die natürlichste Wahl. Optional kann man eine Z-Achse simulieren: Partikel mit kleinem Radius bewegen sich langsam (weit entfernt), Partikel mit großem Radius schnell (nah). Dieser Parallax-Effekt entsteht, indem Geschwindigkeit und Radius bei der Initialisierung proportional verknüpft werden: p.vx = (Math.random() - 0.5) * p.r * 0.3. Das Ergebnis ist ein überzeugender Tiefeneindruck ohne 3D-Bibliothek und ohne Perspektiv-Transformation.
6. Maus-Interaktion: Anziehen und Abstoßen
Maus-Interaktion erhöht das Engagement deutlich: Partikel, die auf die Mausposition reagieren, fühlen sich lebendig an und verleihen der Animation eine spielerische Qualität. Die Implementierung berechnet pro Frame den Abstand jedes Partikels zur aktuellen Mausposition. Liegt ein Partikel innerhalb des konfigurierten mouseRepel-Radius, wird eine Abstandskraft berechnet: Der Vektor vom Partikel zur Maus wird normiert und mit einer Stärkekonstante skaliert, die dann von der Partikel-Geschwindigkeit abgezogen wird.
Für realistische Physik dämpft man die Geschwindigkeit nach der Abstoßung: p.vx *= 0.98 verlangsamt den Partikel jeden Frame leicht, sodass er nach der Abstoßung nicht dauerhaft beschleunigt, sondern zur Ausgangsgeschwindigkeit zurückkehrt. Die Stärke der Abstoßungskraft wird durch den Abstand zur Maus moduliert – nahe Partikel werden stärker abgestoßen als entfernte. Das macht die Interaktion organisch statt abrupt. Ein optionaler Anziehungs-Mode (mit einem Keyboard-Toggle per Alpine x-on:keydown.space) wechselt das Vorzeichen der Kraft.
// Mouse repulsion applied per particle each frame
applyMouseForce(p) {
const dx = p.x - this.mouse.x;
const dy = p.y - this.mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < this.config.mouseRepel && dist > 0) {
const force = (this.config.mouseRepel - dist) / this.config.mouseRepel;
const fx = (dx / dist) * force * 2.5;
const fy = (dy / dist) * force * 2.5;
p.vx += fx;
p.vy += fy;
}
// Dampen velocity back toward original speed
const baseSpeed = p.baseSpeed;
const currentSpeed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (currentSpeed > baseSpeed * 3) {
p.vx *= 0.92;
p.vy *= 0.92;
}
p.vx *= 0.995;
p.vy *= 0.995;
},
// Draw connection lines between nearby particles
drawConnections(W, H) {
const { ctx, particles, config } = this;
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < config.connectDistance) {
const alpha = 1 - dist / config.connectDistance;
ctx.beginPath();
ctx.strokeStyle = `rgba(94, 234, 212, ${alpha * 0.3})`;
ctx.lineWidth = 0.5;
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
}
7. Performance-Optimierung: OffscreenCanvas und devicePixelRatio
Ein Starfield mit 150 Partikeln und Verbindungslinien muss pro Frame O(n²) Abstands-Berechnungen für die Linien durchführen – bei 150 Partikeln sind das 11.175 Berechnungen pro Frame, bei 60 FPS über 670.000 pro Sekunde. Das ist für moderne CPUs kein Problem, aber der Algorithmus sollte trotzdem optimiert werden. Spatial Hashing oder eine einfache Grid-Partitionierung reduziert die Anzahl der Vergleiche drastisch: statt alle Partikel gegeneinander zu prüfen, teilt man den Canvas in Zellen auf und vergleicht nur Partikel in benachbarten Zellen.
Für den devicePixelRatio muss der Canvas intern mit der physikalischen Pixel-Dichte skaliert werden, sonst erscheinen Sterne unscharf auf Retina-Displays. Das Muster ist: canvas.width = logicalWidth * dpr, canvas.style.width = logicalWidth + 'px', dann ctx.scale(dpr, dpr). Alle Zeichenoperationen arbeiten dann in logischen Pixeln, der Browser skaliert das Ergebnis automatisch. Die Verbindungslinien-Berechnung sollte auf einen weiteren Frame ausgelagert werden, wenn die FPS unter 50 fallen – ein einfacher Frame-Counter mit Modulo-Check reicht dafür aus.
8. Responsives Verhalten: ResizeObserver und Canvas-Skalierung
Canvas-Elemente haben keine automatische responsive Größenanpassung – ihre Breite und Höhe müssen manuell gesetzt werden, wenn sich der Container ändert. CSS-Breite und Canvas-interne Auflösung sind getrennte Konzepte: width: 100% im CSS streckt das Canvas visuell, ändert aber nicht die interne Zeichenfläche. Das Ergebnis ist ein unscharfes, gestrecktes Bild. Korrekt ist: den Container mit ResizeObserver beobachten, bei jeder Größenänderung Canvas-Breite und -Höhe neu setzen, den Context neu skalieren und die Partikel-Positionen auf die neue Fläche normieren.
Die einfachste Strategie beim Resize: Canvas-Dimensionen neu setzen (was den Canvas automatisch löscht) und die Partikel-Positionen prozentual auf die neue Größe skalieren. Das verhindert, dass alle Sterne nach einem Resize an denselben absoluten Positionen klumpen, sondern sich proportional über die neue Fläche verteilen. Der ResizeObserver hat einen eingebauten Debounce durch den Browser – er feuert nach dem Layout-Pass, nicht bei jedem Pixel der Größenänderung.
9. Canvas vs. CSS vs. SVG: Wann welcher Ansatz?
Die Wahl zwischen Canvas, CSS-Animationen und SVG-Animationen hängt vom Anwendungsfall ab. Für wenige Elemente mit einfachen Übergängen ist CSS immer die erste Wahl – kein JavaScript, GPU-beschleunigt, deklarativ. Für komplexe, datengetriebene Animationen mit vielen Elementen ist Canvas das richtige Werkzeug. SVG liegt dazwischen: interaktive Grafiken mit Hover-States und DOM-Zugänglichkeit, aber langsamer als Canvas bei vielen Elementen.
| Kriterium | CSS Animation | SVG Animation | Canvas API |
|---|---|---|---|
| Elementanzahl | Bis ~50 Elemente | Bis ~200 Elemente | Tausende Partikel |
| Accessibility | Vollständig | Mit ARIA | Kein DOM-Zugriff |
| Performance bei 150+ Objekten | Reflow, langsam | Mittel | Sehr hoch (GPU) |
| Komplexität | Minimal | Mittel | Höher (JS-Logik) |
| Bibliothek nötig? | Nein | Nein | Nein (nativ) |
Für dekorative Hintergründe mit vielen animierten Elementen ist Canvas die klare Wahl. Die fehlende DOM-Accessibility ist bei einem rein dekorativen Hintergrund kein Problem – das Canvas-Element bekommt aria-hidden="true" und ist für Screenreader unsichtbar. Der eigentliche Seiteninhalt liegt oberhalb des Canvas im normalen DOM-Flow. Diese klare Trennung zwischen Dekoration und Inhalt ist gutes Design.
Mironsoft
Alpine.js Frontend-Entwicklung, Canvas-Animationen und Performance-Optimierung
Performante Canvas-Animationen für euren Webshop?
Wir entwickeln performante, interaktive Canvas-Animationen und Alpine.js-Komponenten für Hyvä Themes – dekorative Hintergründe, Produkt-Konfiguratoren und mehr, ohne externe Animations-Bibliotheken.
Canvas-Animationen
Partikel-Systeme, Hero-Hintergründe, interaktive Visualisierungen – ohne Three.js
Performance-Audit
Bestehende Animationen analysieren, Rendering-Bottlenecks finden und beheben
Alpine.js-Integration
Canvas-Komponenten nahtlos in Hyvä-Theme-Struktur integrieren, CSP-konform
10. Zusammenfassung
Ein animierter Partikel-Hintergrund ohne Bibliothek ist mit Alpine.js und der nativen Canvas API vollständig umsetzbar – in unter 150 Zeilen JavaScript, ohne npm-Paket, ohne Build-Step. Alpine.js übernimmt Lifecycle-Management (init, destroy), Canvas-Referenz über $refs und Konfigurations-Parameter über x-data. requestAnimationFrame sorgt für flüssige, batteriesparende Animation, die automatisch pausiert, wenn der Tab unsichtbar ist. Motion-Blur-Effekt durch partielles Übermalen, Twinkling durch Sinus-Funktion, Maus-Interaktion durch Kraft-Berechnung per Frame.
Die Entscheidung für oder gegen Canvas hängt von der Elementanzahl ab. Unter 50 animierte Elemente: CSS-Transitions. Zwischen 50 und 200: SVG-Animationen. Über 200 Partikel mit Interaktion: Canvas. Für dekorative Hintergründe, die nicht Teil des semantischen Inhalts sind, bleibt Canvas die performanteste und bibliotheksfreie Lösung, die auf allen modernen Browsern ohne Polyfill funktioniert.
Alpine.js Starfield — Das Wichtigste auf einen Blick
Lifecycle mit Alpine
init() startet Animation und ResizeObserver. destroy() stoppt cancelAnimationFrame. $refs.canvas liefert die Canvas-Referenz ohne querySelector.
requestAnimationFrame
Pausiert automatisch bei unsichtbaren Tabs. Frame-ID speichern für cancelAnimationFrame beim Destroy. Kein setInterval nötig.
devicePixelRatio
Canvas intern mit dpr skalieren, dann ctx.scale(dpr, dpr). In logischen Pixeln zeichnen. Verhindert unscharfe Darstellung auf Retina-Displays.
Kein Three.js nötig
Für 2D-Partikel-Hintergründe ist die native Canvas API vollständig ausreichend. Spart 580 KB Bundle-Größe gegenüber Three.js.