Most "particle text" effects you see in showreels are either a pre-baked video loop or a heavyweight WebGL library pulled in just to move some dots around. The Text Particles snippet does the whole thing in plain Canvas 2D — no Three.js, no physics engine, no shaders. Type a word, and a cloud of scattered dots springs together into readable letters. Click, and the word blows apart into a firework of colored particles before quietly reassembling itself.
Here's the effect live — type your own word and try clicking the canvas:
Not seeing particles? Disable your ad blocker or privacy extension for this page and reload.
Grab the code, or open the full editor with live HTML/CSS/JS panels: Text Particles on FWD Tools. It's plain HTML, CSS, and JavaScript, but the same editor has one-click export buttons for React, Vue, Angular, and React + Tailwind if you'd rather drop it into a component-based project than copy raw markup.
In this post I'll walk through the whole thing A to Z — what the effect actually is, where it's genuinely useful, and then the full build: how a word becomes a list of target points, the tiny bit of physics that pulls thousands of particles toward those points, the explode-and-reform state machine, and the rendering choices that keep it smooth. By the end you'll understand it well enough to rebuild it from scratch or bend it into something of your own.
What the effect actually is
Strip away the visual polish and the mechanism is simple to state: a word is drawn once, invisibly, purely to find out which pixels belong to its letters. Every one of those pixels becomes a target point for a particle. Thousands of particles then start at random positions and get nudged toward their assigned target every frame, a little more each time, until they settle into the shape of the word. Click the canvas and every particle instead gets a random outward push plus gravity, so the word disintegrates into a cloud that arcs and falls like a firework — then, a moment later, the same springs pull everything back home.
Nothing about this requires knowing where letters "are" in any geometric sense — no font-outline parsing, no bezier math. The browser's own text renderer draws the glyphs; the code just asks the canvas which pixels ended up lit.
Where you'd actually use this
A particle text effect is a strong first impression, but it earns its keep in a specific set of spots:
- Animated hero titles. A brand name or headline that visibly assembles itself is a much stronger opener than a headline that's just already there when the page loads.
- Landing page intros. Let the word form once on load, then let visitors click it to watch it scatter and reform — a small interaction most visitors will try at least once.
- Event countdowns and launch reveals. Explode a date or a product name apart and let it reassemble to build anticipation before a reveal.
- Game title and score screens. A title screen or "YOU WIN" banner that particles into place reads as far more crafted than a static PNG.
- Interactive brand demos. Let visitors type their own word — a name, a company, a message — and watch it form in your brand's colors.
In every one of these, the appeal is the same: it's a piece of typography that behaves like a physical object, which nothing about plain text or CSS animation naturally gives you.
The tech stack: just the Canvas 2D API
This snippet uses nothing but the browser's built-in Canvas 2D API and requestAnimationFrame — no CDN script tags, no npm install, no build step. The whole trick rests on two Canvas methods most people only use for basic drawing: fillText, to render the word, and getImageData, to read the pixels back out as raw numbers. Everything else — the physics, the state machine, the rendering — is arithmetic run inside a plain animation loop.
If Canvas 2D is new territory for you, the core idea to hold onto is that a canvas is really just a grid of pixel bytes you can both write to (fillRect, fillText, arc, and so on) and read back from (getImageData). Almost every "sample an image and do something with it" effect on the web — this one included — is built on that read-back capability.
Building it, step by step
Step 1 — The HTML and CSS: a canvas, a text input, and two buttons
The markup is deliberately thin — a full-bleed <canvas> plus a small control bar underneath it:
<div class="app">
<canvas id="canvas"></canvas>
<div class="controls">
<input type="text" id="textInput" value="HELLO" maxlength="10" placeholder="Enter text..." spellcheck="false">
<button id="applyBtn">Apply</button>
<button id="toggleBtn">Explode</button>
<span class="hint">Click canvas to toggle</span>
</div>
</div>
The canvas is set to flex: 1 inside a column flex container so it fills all the space above the control bar, and its actual pixel dimensions are set in JavaScript from offsetWidth/offsetHeight rather than CSS alone — a canvas element's drawing buffer and its displayed size are two separate things, and only setting the width/height attributes (not just CSS) gives you a crisp, correctly-scaled drawing surface.
Step 2 — Sampling the word into target points
This is the idea the whole effect is built on, and it's worth sitting with. The particles need to know where the letters are, in pixel coordinates. To find out, the code draws the word onto a hidden, off-screen canvas — one that's never appended to the page — with a large bold font, then calls getImageData to read the entire pixel buffer back as numbers:
const off = document.createElement('canvas');
const FONT_SIZE = Math.min(120, W * 0.25);
off.width = W; off.height = H;
const oc = off.getContext('2d');
oc.fillStyle = '#fff';
oc.font = `bold ${FONT_SIZE}px Arial, sans-serif`;
oc.textAlign = 'center';
oc.textBaseline = 'middle';
oc.fillText(text.toUpperCase(), W/2, H/2);
const imageData = oc.getImageData(0, 0, W, H);
const data = imageData.data;
getImageData returns a flat Uint8ClampedArray with four bytes per pixel — red, green, blue, alpha, in that order, row by row. For a canvas that's never been shown to a user, that's fine; it exists purely as scratch space for this one calculation.
The code then walks that array on a stride — stepping several pixels at a time in both x and y, rather than checking literally every pixel — and for each sampled coordinate checks the alpha byte:
for (let y = 0; y < H; y += stride) {
for (let x = 0; x < W; x += stride) {
const idx = (y * W + x) * 4;
if (data[idx + 3] > 128) {
particles.push({ tx: x, ty: y, x: Math.random() * W, y: Math.random() * H, vx: 0, vy: 0, hue: (x / W) * 360, alpha: 1 });
}
}
}
An alpha value above 128 means that pixel is solidly part of a letter, not background or an anti-aliased edge — so it becomes a particle's target position (tx, ty). The particle itself starts somewhere random on screen, with zero velocity, ready to be pulled toward that target once the animation loop starts. The stride is the effect's main density-vs-performance dial: a smaller stride samples more pixels, giving denser, crisper letters at a higher particle count and cost; a larger stride is sparser and cheaper. A stride of a few pixels comfortably produces a few thousand particles for a clean, readable word.
Step 3 — The spring: making particles ease into place
Every particle carries a current position (x, y), a velocity (vx, vy), and its target (tx, ty). Each frame, in the FORM state, it runs the same four lines:
p.vx += (p.tx - p.x) * 0.08;
p.vy += (p.ty - p.y) * 0.08;
p.vx *= 0.85;
p.vy *= 0.85;
p.x += p.vx;
p.y += p.vy;
This is a damped spring, and it's one of the most useful four-liners in animation programming — worth understanding well past this one effect. The first two lines are the "spring" half: velocity gains a push proportional to how far the particle still is from its target, so a particle that's far away accelerates hard, like a stretched rubber band, while a particle that's nearly there barely gets nudged. Left alone, that would make every particle overshoot and oscillate forever, like a mass on an ideal spring with no friction.
The next two lines are the "damping" half: velocity is multiplied by a factor just under 1 every frame, bleeding off a little energy each time. That's the friction. Tune the spring constant (0.08 here) and the damping factor (0.85) together and you control the whole feel of the motion — a stiffer spring with less damping snaps into place with a visible overshoot and wobble; a softer spring with more damping eases in smoothly with no bounce at all. Because every particle runs this same tiny simulation completely independently of every other one, a cloud of thousands of them organically resolves into readable text with no coordination logic required anywhere.
Step 4 — The state machine: FORM and EXPLODE
The whole effect is driven by one variable, state, that's either 'FORM' or 'EXPLODE'. Clicking the canvas (or the toggle button) flips it:
function explode() {
state = 'EXPLODE';
particles.forEach(p => {
p.vx = (Math.random() - 0.5) * 16;
p.vy = -5 - Math.random() * 10;
p.alpha = 1;
});
}
Every particle is handed a random horizontal velocity and an upward-biased vertical velocity — enough to make the whole word visibly burst outward and up rather than just drifting apart. From there, the update loop for the EXPLODE state applies simple projectile motion:
particles.forEach(p => {
p.vy += 0.15; // gravity
p.x += p.vx;
p.y += p.vy;
p.alpha -= 0.008; // fade out
});
particles = particles.filter(p => p.alpha > 0 && p.y < H + 50);
A constant downward acceleration added to vy every frame is gravity — no different from the physics you'd write for a simple platformer. Particles fade as they fall and get pruned once they're transparent or have dropped off the bottom of the canvas, so the array doesn't grow unbounded. When reform() is called, every particle is simply thrown back to a random position with zero velocity and the state flips back to FORM — at which point the exact same spring code from Step 3 takes back over and pulls everything home. The explosion and the reassembly are two completely different pieces of motion logic, but they operate on the exact same particle array, which is what makes toggling between them instant and seamless.
Step 5 — Rendering thousands of particles without dropping frames
The draw step runs once per frame, after the physics update:
ctx.fillStyle = 'rgba(13,13,26,0.25)';
ctx.fillRect(0, 0, W, H);
particles.forEach(p => {
ctx.globalAlpha = Math.max(0, p.alpha);
ctx.fillStyle = `hsl(${p.hue}, 80%, 65%)`;
ctx.fillRect(p.x, p.y, 2, 2);
});
ctx.globalAlpha = 1;
Two choices here matter for performance. First, instead of clearing the canvas outright each frame, it paints a semi-transparent rectangle over the whole thing. Because that rectangle doesn't fully erase what's underneath, every particle leaves a very short motion trail behind it before it fades to background — a cheap way to make fast-moving particles read as smoother streaks rather than flickering dots, for the cost of one fillRect call.
Second, each particle is drawn with fillRect(x, y, 2, 2) — a tiny filled square — rather than the more "correct-looking" arc() circle. Building a circular path and filling it is meaningfully more expensive than filling a rectangle, and at a small enough size the visual difference between a 2px square and a 2px circle is imperceptible. With a few thousand particles updating and drawing every frame, that one substitution is often the difference between a smooth 60fps and a stutter.
Color comes from each particle's horizontal position at the moment it was sampled — hue: (x / W) * 360 — mapped straight into an HSL color string. Because that hue is assigned once per particle and never changes, the assembled word displays a smooth left-to-right rainbow gradient that rides along with the particles even while they're mid-flight during an explosion.
Step 6 — Changing the word without a hard cut
Typing a new word into the input and pressing Apply doesn't restart the effect from scratch — it re-samples the offscreen canvas with the new text and rebuilds the particle array, but each particle still just gets a fresh (tx, ty) target and resumes the same spring behavior from Step 3. Because the particles' actual on-screen positions aren't reset, the transition from one word to the next plays out as a smooth animated morph — the old shape's cloud of dots simply gets reassigned new destinations and drifts into the new word — rather than a jarring cut from one static word to another.
Customizing it for your own project
A few knobs worth knowing about if you're adapting this:
- Density vs. performance — lower the sampling stride for denser, crisper text at a higher particle count and CPU cost, or raise it for a sparser, faster effect. The particle count always follows the letters' pixel area automatically.
- Motion feel — raise the spring constant or lower the damping factor for a snappier assembly with visible overshoot; do the opposite for a slower, floatier ease-in with no bounce at all.
- Explosion strength — the random velocity ranges in
explode()and the gravity constant in the update loop control how violently the word bursts apart and how quickly it falls; scale them together to keep the arc looking natural. - Color scheme — the hue is derived from horizontal position, but it could just as easily be a fixed brand color, mapped from vertical position instead, or randomized per particle for a more chaotic palette.
Using it in React, Vue, or Angular
The one rule that matters when porting a canvas animation loop into a component framework is: set up the canvas, sample the text, and start requestAnimationFrame inside a mount lifecycle hook — useEffect in React, onMounted in Vue, ngAfterViewInit in Angular — never at module or render time, since the canvas element and its dimensions need to exist first. Store the particle array and animation frame ID in a ref (React) or a component field (Vue/Angular) rather than component state, since re-rendering per particle, per frame, would fight the animation loop and tank performance. On unmount, call cancelAnimationFrame with the stored ID so the loop doesn't keep running against a canvas that's no longer in the DOM. When the displayed word is passed in as a prop, re-run the sampling step whenever it changes, exactly like the Apply button does in the vanilla version.
The FWD Tools snippet editor has one-click exports for all of this already built if you'd rather skip writing the conversion by hand — grab the React, Vue, Angular, or Tailwind version directly from the snippet page.
Final thought
What makes this snippet worth studying isn't the particle text effect itself — it's the underlying pattern: decouple the "where" from the "how." An offscreen canvas plus getImageData answers where the text is, producing nothing more than a flat list of target points. A damped spring answers how anything gets from A to B, and it has no idea it's moving toward letters — it would just as happily pull particles toward a logo, a photo's silhouette, or a mouse cursor. Once that split clicks, the same two ingredients — pixel sampling for targets, spring-and-damping for motion — turn into image reveals, morphing shapes, and cursor-following swarms, all built from the same handful of lines.
Grab the full HTML, CSS, and JS — or export it straight to React, Vue, Angular, or Tailwind — at Text Particles on FWD Tools. It's free, runs entirely in your browser, and needs no sign-up. Browse more canvas and animation effects like this one at FWD Tools UI Snippets — Animations.
