Home

Rendering Particles Without a Canvas

Every ball is a CSS box-shadow on one 1px div. JavaScript moves the balls and updates the shadows each frame.

This was my first post. I rebuilt three demos from David Gerrells' How not to use box shadows, with live controls.

1. Gravity and walls

Raise Gravity to pull the balls down. They bounce off the edges, and Damping slows them between bounces. Press and drag inside the frame to attract nearby balls.

Gravity & bounce
press and drag to attract
Controls
150
0
98
4000

Each frame updates velocity and position. A wall collision reverses the velocity on that axis and reduces it. dt is the simulation step in seconds:

// Each frame, for each ball:
b.vy += gravity * dt;

// Pointer attraction
if (pointerDown && dist > 1 && dist < 300) {
  var f = (1 - dist / 300) * pull;
  b.vx += (dx / dist) * f * dt;
  b.vy += (dy / dist) * f * dt;
}

// Reduce speed
b.vx *= damping;
b.vy *= damping;

// Move
b.x += b.vx * dt;
b.y += b.vy * dt;

// Bounce off the bottom edge
var r = (b.size * (1 + b.z/40)) / 2;
if (b.y > H - r) {
  b.y = H - r;
  b.vy = -Math.abs(b.vy) * 0.7;
}

Negative gravity pulls upward. Damping at 100 stops the slowdown between bounces; wall collisions still reduce speed. Raise Pull to increase the attraction.


2. Collisions

This version checks every pair of balls. When their centers are too close, it separates them and changes their velocities along the line joining them. Doubling the count roughly quadruples the number of checks.

Collisions
press and drag to attract
Controls
150
9
0
60
2400

The test uses each ball's size as its collision radius. Move overlapping balls half the overlap distance each. Then adjust their velocities if they're moving toward each other:

var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx*dx + dy*dy);
var minD = a.size + b.size;

if (dist < minD && dist > 0.1) {
  // Push apart
  var nx = dx/dist, ny = dy/dist;
  var overlap = minD - dist;
  a.x -= nx * overlap * 0.5;
  a.y -= ny * overlap * 0.5;
  b.x += nx * overlap * 0.5;
  b.y += ny * overlap * 0.5;

  // Adjust velocity along the collision axis
  var dvn = (a.vx-b.vx)*nx + (a.vy-b.vy)*ny;
  if (dvn > 0) {
    a.vx -= dvn * nx * bounce;
    a.vy -= dvn * ny * bounce;
    b.vx += dvn * nx * bounce;
    b.vy += dvn * ny * bounce;
  }
}

At Bounce 100, ball-to-ball collisions preserve the pair's energy of motion. A separate slowdown still applies each frame. Raise Gravity, Count, and Size to build a pile.


3. The sponge

Each ball has a fixed starting position. A spring force pulls it back, with a stronger pull the farther it moves away. Drag to displace nearby balls, then release to let them return.

Sponge
press and drag to pull
Controls
160
7.5
2700
260
97

On each axis, the spring changes velocity in proportion to the distance from the starting position:

b.vx += (b.homeX - b.x) * springK * dt;
b.vy += (b.homeY - b.y) * springK * dt;

Raise Spring for a quicker return. Lower it for a slower one. Radius sets how far your pointer's pull reaches.


Drawing with box-shadow

Each ball is an entry in a comma-separated box-shadow list. Its entry sets the position, size, and color, with zero blur. Every frame builds a new list and assigns it to the div:

balls.sort(function(a,b) { return b.z - a.z; });
var s = [];
for (var i=0; i<balls.length; i++) {
  var b = balls[i];
  var zScale = (mode === 'collide') ? 1 : 1 + b.z/40;
  var spread = (b.size * zScale - 1) / 2;
  s.push(b.x+'px '+b.y+'px 0 '+spread+'px '+b.color);
}
dot.style.boxShadow = s.join(',');

The z value sets the drawing order. In the gravity and spring demos, it also scales the size to suggest depth. CSS paints the first shadow in front, so higher z values come first in the list.

Shadows don't change the element's layout, but the browser still has to process and paint the updated list. The collision demo also has to check every pair. The full script contains all three simulations and the shared renderer.