Home

Rendering Particles Without a Canvas

No canvas, no WebGL, no SVG. Three physics demos where the entire renderer is one CSS property on a single 1px div.

My first blog post. The idea is entirely David Gerrells', from his brilliant, unhinged box-shadow experiments; I just rebuilt it for my own attention span.

1. Gravity and walls

The simplest version. Balls fall under gravity, bounce off the edges, and lose a bit of energy on each bounce. Click or touch to pull them toward your cursor.

Gravity & bounce
click & drag to attract
Controls
150
0
98
4000

The whole update is a for loop. Add gravity to vertical velocity. If the cursor is pressed, add an attraction force. Apply damping to bleed off energy. Move. If a ball hits a wall, flip the velocity:

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

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

// Damping bleeds energy
b.vx *= damping;
b.vy *= damping;

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

// Bounce off walls
if (b.y > H) { b.y = H; b.vy *= -0.7; }

Crank gravity negative and they fall upward. Set damping to 100 and they drift forever. Pull at 10,000 turns your cursor into a black hole.


2. Collisions

Same setup, but now the balls notice each other. When two overlap, they push apart and exchange velocity along the line between them. The check is brute force (every ball against every other ball), which is fine at this scale and terrible above a few thousand.

Collisions
click & drag to attract
Controls
150
9
0
60
2400

For each pair, measure the distance. If it is less than the sum of the two radii, push them apart and reflect their velocities along the collision normal:

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) {
  // Push apart
  var nx = dx/dist, overlap = minD - dist;
  a.x -= nx * overlap * 0.5;
  b.x += nx * overlap * 0.5;

  // Swap velocity along normal
  var dvn = (a.vx-b.vx)*nx + (a.vy-b.vy)*ny;
  a.vx -= dvn * nx * bounce;
  b.vx += dvn * nx * bounce;
}

Bounce at 100 gives elastic collisions (no energy lost). Bounce at 10 makes them behave like wet clay. Crank the count and size up together to watch them pile.


3. The sponge

My favorite. Each ball remembers where it was born and wants to go back. A spring force pulls it home, and the farther it gets the stronger the pull. Your cursor overpowers the spring locally. Let go and everything snaps back.

Sponge
click & drag to tear
Controls
160
7.5
2700
260
97

The spring is one line per axis. Displacement from home times a constant:

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

Set the spring to 12 and the balls snap home instantly. Set it to 0.5 and they drift back like they are underwater. Lower the radius and you have to get very close before they react to you.


The renderer is one CSS property

Here is the whole trick: every ball is a single shadow. Not a metaphor. One element can hold hundreds of comma-separated box-shadow offsets, so each frame you sort them back-to-front, build the string, and assign it once:

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

The browser paints all of them on the same path that draws the shadow under a button, no drawing API, no buffers. Changing box-shadow repaints but never lays the page out again, so a few hundred shadows fly and a few thousand crawl, with no GPU instancing to save them. The wrong way to render anything serious, and glorious for this.