Particles
I wanted to build something visual. Something you could interact with.
Most "impressive" graphics on the web require WebGL, Three.js, or shader knowledge. I wanted to see how far I could get with just the Canvas 2D API - the thing browsers have supported since 2004.
Hover over the text to scatter the particles.
Trick
The core idea is simple: don't parse the text, sample pixels.
- Render text to an invisible canvas using
fillText() - Read the pixel data with
getImageData() - Randomly sample coordinates
- If a pixel has alpha > 128, it's part of the text - spawn a particle there
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
// Try random coordinates
const x = Math.floor(Math.random() * canvas.width)
const y = Math.floor(Math.random() * canvas.height)
// Check if this pixel is part of the text
const index = (y * canvas.width + x) * 4
if (data[index + 3] > 128) {
// Alpha channel > 128 means this pixel is "filled"
createParticleAt(x, y)
}
This works for any text, any font, any size. The algorithm doesn't care what the text says - it just sees pixels.
Interaction
Each particle stores two positions: where it currently is (x, y) and where it belongs (baseX, baseY).
Every frame:
- Calculate distance from particle to cursor
- If within range, push it away
- Otherwise, ease it back toward base
const dx = mouseX - particle.x
const dy = mouseY - particle.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < 240) {
// Push away from cursor
const force = (240 - distance) / 240
const angle = Math.atan2(dy, dx)
particle.x = particle.baseX - Math.cos(angle) * force * 60
particle.y = particle.baseY - Math.sin(angle) * force * 60
} else {
// Ease back to base position (10% per frame)
particle.x += (particle.baseX - particle.x) * 0.1
particle.y += (particle.baseY - particle.y) * 0.1
}
The 0.1 easing factor is important. Too high and particles snap back instantly. Too low and they drift forever. 0.1 feels organic - particles return quickly but not mechanically.
Shimmer
If you look closely, the text shimmers even when you're not interacting with it.
Each particle has a life counter (50-150 frames). When life hits zero, the particle "dies" and respawns at a new random position within the text. This creates subtle movement without any explicit animation code.
It also means the particle distribution stays even. Without respawning, particles would gradually cluster in certain areas due to the random sampling.
Performance
7,000 particles at 60fps. Not bad for Canvas 2D.
The bottleneck isn't the math - modern JavaScript handles 7,000 distance calculations easily. The bottleneck is draw calls. Each fillRect() is a separate operation.
Optimizations I considered but didn't implement:
- Batch rendering - Draw all particles of the same color in one path
- Web Workers - Offload physics to another thread
- WebGL - Different API entirely, 100x more particles possible
For 7,000 particles, none of these are necessary. The code stays simple, runs fast enough.
If I needed 100,000 particles, I'd switch to WebGL. That's a different project.
Responsive
Long text on a narrow screen overflows. My fix:
const maxWidth = canvas.width * 0.9
const baseFontSize = isMobile ? 60 : 120
// Measure at base size
ctx.font = `bold ${baseFontSize}px ${fontFamily}`
const textWidth = ctx.measureText(fullText).width
// Scale down if needed
const fontSize = textWidth > maxWidth
? Math.floor(baseFontSize * (maxWidth / textWidth))
: baseFontSize
Text shrinks to fit but never grows beyond the base size.
Retina
Canvas has two sizes: CSS pixels (what you see) and backing store pixels (what it renders).
On a 2x Retina display, a 400px-wide canvas only has 400 pixels in memory by default. The browser upscales it, making everything blurry.
Fix:
const dpr = window.devicePixelRatio || 1
// Backing store at full resolution
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
// CSS size stays the same
canvas.style.width = rect.width + 'px'
canvas.style.height = rect.height + 'px'
Then scale all your rendering (font sizes, particle sizes, coordinates) by dpr. Sharp pixels on any display.
Bug
When I first built this, setting both text strings to empty crashed the browser.
The particle spawning loop kept trying to find text pixels, found none, and looped forever:
// This runs every frame
while (particles.length < targetCount) {
const particle = createParticle() // Returns null if no text
if (particle) particles.push(particle)
// If particle is always null, this never exits
}
Fix: before spawning, scan the image data once to check if any text pixels exist. If not, skip particle creation entirely.
Learnings
Canvas 2D is underrated. Most developers reach for WebGL or animation libraries immediately. For many effects, the 2D API is enough - and the code is dramatically simpler.
The pixel sampling trick is powerful. You can apply it to any shape - text, images, SVGs. Render to canvas, sample pixels, create particles. The algorithm generalizes.
Performance intuition matters. I guessed 7,000 particles would be fine, and it was. I also knew 100,000 wouldn't be. Knowing when to optimize (and when not to) saves time.
Stack
- Bun 1.3 - HTML entrypoint bundling (no framework needed)
- React 19 - Just for the component structure
- Canvas 2D API - All rendering
- TypeScript - Type safety
- Vercel - Static hosting
No animation library. No physics engine. About 300 lines of code.
Demo
The demo at the top of this post is interactive. Move your cursor over it, or drag the slider to adjust particle count.
Or visit the standalone version: rp-particles.vercel.app