Particle Background Without a Library
Three.js weighs in at 600 KB, and tsParticles brings even more dependencies along with it. For an animated night sky or particle background you need neither. The native Canvas API with requestAnimationFrame and Alpine.js x-data delivers the same visual result in under 100 lines, with no build step, no npm, and no bundle overhead.
Table of Contents
- 1. Why Canvas Instead of CSS Animations or a Library?
- 2. Alpine.js and Canvas: x-data as the Animation Controller
- 3. The Particle System: Initialization and Base Properties
- 4. The Animation Loop with requestAnimationFrame
- 5. Particle Physics: Movement, Edge Handling, and Lifetime
- 6. Mouse Interaction: Attracting and Repelling
- 7. Performance Optimization: OffscreenCanvas and devicePixelRatio
- 8. Responsive Behavior: ResizeObserver and Canvas Scaling
- 9. Canvas vs. CSS vs. SVG: Which Approach and When?
- 10. Summary
- 11. FAQ
1. Why Canvas Instead of CSS Animations or a Library?
CSS animations are ideal for simple, declarative transitions such as color, opacity, or transform on individual elements. But once you need to animate hundreds of particles with individual positions, speeds, and interactions, CSS hits its limits. Each particle as its own DOM element would overload the browser with reflow calculations. The Canvas API sidesteps this problem entirely: instead of DOM elements, it draws directly onto a bitmap that is rendered with GPU acceleration. Hundreds of particles run at 60 FPS without triggering a single DOM manipulation.
Libraries such as Three.js or tsParticles abstract these Canvas operations and offer ready made particle systems. For complex 3D scenes, Three.js is indispensable. But for a simple starfield or particle background on a website, these abstractions are overkill: Three.js adds 580 KB minified, and tsParticles adds another 100 to 200 KB depending on configuration. With the native Canvas API and Alpine.js x-data, there is nothing to load at all. The browser already provides everything this use case needs.
In this setup, Alpine.js takes on the role of lifecycle manager: init() starts the animation, and destroy() (triggered via x-on:unload) stops the loop and frees resources. You get the Canvas reference through this.$refs.canvas inside the Alpine component. That keeps the whole setup maintainable and prevents memory leaks caused by forgotten requestAnimationFrame loops during page navigation in single page setups.
2. Alpine.js and Canvas: x-data as the Animation Controller
The x-data object of the starfield widget holds all the state variables that drive the animation: the particle array, the Canvas reference, the 2D rendering context, the current frame ID of the requestAnimationFrame call, and the mouse position. These variables are not reactive in the Alpine sense, since Alpine does not need to update the DOM on every frame. The Canvas content is updated directly through JavaScript drawing operations. Here, x-data acts as a structured container for the animation state rather than a reactive data store.
The init() method, which Alpine calls automatically once the DOM is ready, initializes the Canvas: it sets width and height to the parent element's dimensions, obtains the 2D context with getContext('2d'), generates the initial particle population, and starts the animation loop. A ResizeObserver on the container element makes sure the Canvas is rescaled whenever the window size changes. Alpine's $nextTick guarantees that the Canvas DOM node already exists when init() runs and is reachable via this.$refs.canvas.
// 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. The Particle System: Initialization and Base Properties
Each particle object holds the properties that describe its movement and appearance: position (x, y), velocity (vx, vy), radius (r), opacity (alpha), and optionally an individual color. These objects are stored as plain JavaScript literals, with no class overhead and no prototype lookup. The spawnParticles() method creates the array with count particles, distributes them randomly across the Canvas area, and assigns each one a random velocity and radius, normalized against the configured maximum speed.
Particle radius follows a quadratic distribution: most stars are small, and only a few are large. This produces a more realistic sense of depth. Opacity varies between 0.3 and 1.0 and flickers slightly every frame (alpha += Math.sin(frame * 0.02 + phase) * 0.005). This subtle twinkling of the stars happens without any additional animation logic, just a simple sine function with an individual phase offset per particle.
4. The Animation Loop with requestAnimationFrame
requestAnimationFrame is the browser native API for smooth animations: it synchronizes the draw call with the monitor refresh rate (typically 60 Hz or 120 Hz) and automatically pauses on hidden tabs. That saves CPU and battery when the page is not visible, a feature that would otherwise have to be rebuilt manually via the Page Visibility API for setInterval based animation loops. The loop() method calls itself recursively via requestAnimationFrame and stores the returned frame ID in this.animFrameId.
At the start of each frame, the Canvas is not fully cleared but instead painted over with a semi transparent rectangle: ctx.fillStyle = 'rgba(15, 23, 42, 0.15)' produces a motion blur effect that makes the particles' movement trail visible. The higher the alpha value of this overlay rectangle, the shorter the trail. A value of 1.0 is equivalent to fully clearing the Canvas. This trick is simpler and more performant than storing particle positions from the last 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. Particle Physics: Movement, Edge Handling, and Lifetime
The simplest particle physics model is linear: position += velocity per frame. Without any additional forces, the stars move in straight, uniform lines across the Canvas. Edge handling determines the visual behavior: with wrap around (tunneling), a particle that leaves the right edge reappears on the left. This creates seamless, endless motion reminiscent of an old space arcade game. Alternatives include reflection (vx *= -1 on edge contact) or respawning at a random position on the opposite edge.
For a starfield effect, wrap around is the most natural choice. Optionally, you can simulate a Z axis: particles with a small radius move slowly (far away), while particles with a large radius move fast (close up). This parallax effect is created by linking velocity and radius proportionally during initialization: p.vx = (Math.random() - 0.5) * p.r * 0.3. The result is a convincing sense of depth without a 3D library and without any perspective transformation.
6. Mouse Interaction: Attracting and Repelling
Mouse interaction noticeably increases engagement: particles that react to the mouse position feel alive and give the animation a playful quality. The implementation calculates the distance of every particle to the current mouse position on each frame. If a particle falls within the configured mouseRepel radius, a repulsion force is calculated: the vector from the particle to the mouse is normalized and scaled by a strength constant, which is then subtracted from the particle's velocity.
For realistic physics, velocity is dampened after the repulsion: p.vx *= 0.98 slightly slows the particle every frame, so that it does not stay permanently accelerated after being repelled but instead returns toward its original speed. The strength of the repulsion force is modulated by the distance to the mouse, so nearby particles are repelled more strongly than distant ones. That makes the interaction feel organic rather than abrupt. An optional attraction mode (toggled with a keyboard shortcut via Alpine's x-on:keydown.space) flips the sign of the force.
// 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 Optimization: OffscreenCanvas and devicePixelRatio
A starfield with 150 particles and connection lines has to run O(n squared) distance calculations for the lines on every frame. At 150 particles that is 11,175 calculations per frame, or more than 670,000 per second at 60 FPS. That is not a problem for modern CPUs, but the algorithm is still worth optimizing. Spatial hashing or a simple grid partitioning drastically reduces the number of comparisons: instead of checking every particle against every other particle, you divide the Canvas into cells and only compare particles in neighboring cells.
To handle devicePixelRatio, the Canvas must be scaled internally to the physical pixel density, otherwise stars appear blurry on Retina displays. The pattern is: canvas.width = logicalWidth * dpr, canvas.style.width = logicalWidth + 'px', then ctx.scale(dpr, dpr). All drawing operations then work in logical pixels, and the browser scales the result automatically. The connection line calculation should be offloaded to every other frame once the FPS drops below 50. A simple frame counter with a modulo check is enough for that.
8. Responsive Behavior: ResizeObserver and Canvas Scaling
Canvas elements have no automatic responsive sizing. Their width and height must be set manually whenever the container changes. CSS width and the Canvas's internal resolution are separate concepts: width: 100% in CSS stretches the Canvas visually but does not change the internal drawing surface. The result is a blurry, stretched image. The correct approach is to observe the container with ResizeObserver, reset Canvas width and height on every size change, rescale the context, and normalize particle positions to the new area.
The simplest strategy on resize: reset the Canvas dimensions (which automatically clears the Canvas) and scale particle positions proportionally to the new size. This prevents all the stars from clumping at the same absolute positions after a resize, instead distributing them proportionally across the new area. ResizeObserver has built in debouncing from the browser. It fires after the layout pass, not on every pixel of the size change.
9. Canvas vs. CSS vs. SVG: Which Approach and When?
The choice between Canvas, CSS animations, and SVG animations depends on the use case. For a handful of elements with simple transitions, CSS is always the first choice: no JavaScript, GPU accelerated, and declarative. For complex, data driven animations with many elements, Canvas is the right tool. SVG sits in between: interactive graphics with hover states and DOM accessibility, but slower than Canvas with many elements.
| Criterion | CSS Animation | SVG Animation | Canvas API |
|---|---|---|---|
| Element count | Up to ~50 elements | Up to ~200 elements | Thousands of particles |
| Accessibility | Full | With ARIA | No DOM access |
| Performance at 150+ objects | Reflow, slow | Medium | Very high (GPU) |
| Complexity | Minimal | Medium | Higher (JS logic) |
| Library required? | No | No | No (native) |
For decorative backgrounds with many animated elements, Canvas is the clear choice. The lack of DOM accessibility is not a problem for a purely decorative background: the Canvas element gets aria-hidden="true" and is invisible to screen readers. The actual page content sits above the Canvas in the normal DOM flow. This clean separation between decoration and content is good design.
Mironsoft
Alpine.js frontend development, Canvas animations, and performance optimization
Need performant Canvas animations for your webshop?
We build performant, interactive Canvas animations and Alpine.js components for Hyva themes: decorative backgrounds, product configurators, and more, without external animation libraries.
Canvas animations
Particle systems, hero backgrounds, interactive visualizations, all without Three.js
Performance audit
Analyze existing animations, find and fix rendering bottlenecks
Alpine.js integration
Integrate Canvas components seamlessly into your Hyva theme structure, CSP compliant
10. Summary
An animated particle background without a library is fully achievable with Alpine.js and the native Canvas API, in under 150 lines of JavaScript, with no npm package and no build step. Alpine.js handles lifecycle management (init, destroy), the Canvas reference via $refs, and configuration parameters via x-data. requestAnimationFrame delivers smooth, battery friendly animation that pauses automatically when the tab is hidden. Motion blur comes from partial overpainting, twinkling from a sine function, and mouse interaction from a per frame force calculation.
The decision for or against Canvas depends on the element count. Under 50 animated elements: CSS transitions. Between 50 and 200: SVG animations. Over 200 particles with interaction: Canvas. For decorative backgrounds that are not part of the semantic content, Canvas remains the most performant, library free solution, working on all modern browsers without a polyfill.
Alpine.js Starfield: The Essentials at a Glance
Lifecycle with Alpine
init() starts the animation and the ResizeObserver. destroy() stops it via cancelAnimationFrame. $refs.canvas gives you the Canvas reference without a querySelector.
requestAnimationFrame
Automatically pauses on hidden tabs. Store the frame ID for cancelAnimationFrame on destroy. No setInterval needed.
devicePixelRatio
Scale the Canvas internally with dpr, then ctx.scale(dpr, dpr). Draw in logical pixels. Prevents blurry rendering on Retina displays.
No Three.js needed
For 2D particle backgrounds, the native Canvas API is fully sufficient. Saves 580 KB of bundle size compared to Three.js.