15,000 Particles Forming a Letter
The hero of my portfolio is a giant "4" made of 15,000 floating particles. They drift, respond to mouse movement, and morph between shapes. Here's how I built it.
The Problem: Sampling Points from a Font Glyph
You can't just place particles at pixel positions. A font glyph is a vector path — you need to convert it into thousands of discrete 3D coordinates.
The approach: render the glyph to a canvas, read the pixel data, and collect positions where pixels are non-transparent.
// In a web worker
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.font = `${fontSize}px Outfit`;
ctx.fillText("4", 0, fontSize);
const imageData = ctx.getImageData(0, 0, width, height);
const points: [number, number, number][] = [];
for (let y = 0; y < height; y += step) {
for (let x = 0; x < width; x += step) {
const alpha = imageData.data[(y * width + x) * 4 + 3];
if (alpha > 128) {
points.push([x - width/2, -(y - height/2), 0]);
}
}
}
This gives you a dense grid of points. But there's a problem.
The Clipping Bug
My first version sampled points sequentially — take the first 15,000 points from the grid. This works when your grid has way more points than particles.
But on mobile, I reduced the particle count to 8,000 for performance. Sequential sampling from an ordered grid means you get the top rows of the glyph but the bottom gets cut off. The "4" was missing its foot.
The fix: random sampling.
// Shuffle the points array, then take the first N
function seededShuffle(arr: any[], seed: number) {
let m = arr.length;
while (m) {
const i = Math.floor(seededRandom(seed++) * m--);
[arr[m], arr[i]] = [arr[i], arr[m]];
}
return arr;
}
const sampled = seededShuffle([...points], 42).slice(0, particleCount);
Seeded random gives deterministic results — the same shuffle every time, so the particle shape doesn't change between renders. But the points are evenly distributed across the entire glyph regardless of count.
Web Worker for Performance
Point sampling takes ~50ms on desktop, ~200ms on mobile. That's too long to block the main thread during page load.
Moving it to a web worker means the particle positions compute in the background while the page renders. When the worker finishes, particles appear smoothly.
// Main thread
const worker = new Worker(new URL("./particle-worker.ts", import.meta.url));
worker.postMessage({ glyph: "4", count: 15000, width: 800 });
worker.onmessage = (e) => setPositions(e.data.positions);
Mouse Interaction
When the cursor moves near a particle, it pushes away. The physics are simple — calculate the distance from cursor to particle, apply a repulsion force that falls off with distance.
// In the animation loop
const dx = mouseX - particle.x;
const dy = mouseY - particle.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < repulsionRadius) {
const force = (1 - dist / repulsionRadius) * strength;
particle.vx -= (dx / dist) * force;
particle.vy -= (dy / dist) * force;
}
// Spring back to original position
particle.vx += (particle.originX - particle.x) * springStrength;
particle.vy += (particle.originY - particle.y) * springStrength;
// Damping
particle.vx *= 0.95;
particle.vy *= 0.95;
The spring force pulls particles back to their original position. Damping prevents oscillation. The result feels organic — push particles away, they flow back.
Mobile: Touch Instead of Mouse
On touch devices, I added double-tap to trigger the morph effect and touch-move for interaction. The physics values scale with viewport size so the interaction doesn't feel too strong or weak on different screens.
Performance
- Desktop (1920px): 15,000 particles, 60fps
- Laptop (1440px): 12,000 particles, 60fps
- Tablet: 10,000 particles, 55-60fps
- Mobile: 8,000 particles, 50-60fps
The particle size also scales — sub-pixel particles (< 1px) disappear at screen edges on some GPUs. I enforce a minimum pointSize based on the device pixel ratio.
Want to see it live? Visit 4ugusta.dev. Building something similar? 4UGUSTA Systems can help.