GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Text Particle Effect CSS Ideas From Pixels

A text particle effect built with CSS keyframes always drags the whole letter as one block.

Auto-plays · click a tile to jump to its section · all nine in one zip

Demo

Nine boards, one pipeline: draw a word on an invisible canvas, read its pixels back, and rebuild the word out of whatever material the board favors — particles, halftone dots, threads, packed circles, torn rows, ASCII glyphs. The morph board sweeps the widest footprint of the set at a measured 41.55 percent cumulative area, while the noise warp covers the least ground at 30.59 percent and compensates with a mean intensity of 151.0; every board moved in 23 of 23 sampled frames.

Each board also takes input — type a different word or seed into the field and the mask is rebuilt and every target recomputed on the spot, which is the real difference from a particle text reveal after effects template that renders once and exports a finished clip. Nothing on this page is a video: the word you see is being sampled while you watch.

Why this order

The sequence follows what the letters are made of, not how loud the result is. The dust-to-word condensation sets the prototype (01), the samples are then re-stamped as dots, threads, and circles (02, 03, 04), the mask itself gets torn, washed, and morphed (05, 06, 07), and the last two change how the mask is read — as density, and as a force field around the pointer (08, 09). Results for a particle text effect css search usually land on whole-letter keyframes or heavyweight WebGL demos, and this set deliberately sits between them: plain 2D canvas, no library, no build step. It is also a different axis from the neighbors on this site — the noise terrain boards compute their material out of a noise function and the raymarching scene traces rays into a 3D field, while these boards re-read letters that were already drawn.

Per-item code

01Particle formation — dust condensing into letters

Particle formation — dust condensing into letters
Open in a new tab

01 · Particle formation — dust condensing into letters — Measured area 30.128% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillTextgetImageDataarc

How the mask is built and how particles find it

The word is drawn offscreen with fillText, its alpha mask read back through getImageData, and every particle is handed one target pixel to spring toward — the prototype the other eight boards remix, and the standard move for a landing hero wordmark reveal.

// 1) word → alpha mask
function buildMask(word) {
  var off = document.createElement('canvas');
  off.width = SW; off.height = SH;
  var oc = off.getContext('2d', { willReadFrequently: true });
  oc.textAlign = 'center'; oc.textBaseline = 'middle';
  var fs = 200;
  oc.font = '900 ' + fs + 'px Arial';
  var w = oc.measureText(word).width || 1;
  fs = Math.min(fs * ((SW - 8) / w), SH - 6);       // largest size that fits
  oc.font = '900 ' + Math.round(fs) + 'px Arial';
  oc.fillStyle = '#fff';
  oc.fillText(word, SW / 2, SH / 2);
  return oc.getImageData(0, 0, SW, SH).data;
}
// 2) mask → list of target points
function samplePoints(data) {
  var pts = [];
  for (var y = 0; y < SH; y += STEP) {
    for (var x = 0; x < SW; x += STEP) {
      if (data[(y * SW + x) * 4 + 3] > 128) pts.push({ x: x, y: y });
    }
  }
  return pts;
}

A spring breathing between scatter and cluster

Sizing the word with measureText so it fills the frame and stamping it white leaves the alpha channel shaped exactly like the glyphs, and walking that buffer at a 2px stride while keeping every cell above alpha 128 is the two-step sampler all nine boards start from. Each frame, the targets breathe in and out around the board center with spring pull 0.014 and damping 0.86, so the dust condenses into the word and loosens back on a 2s cycle (measured: cumulative area 30.13 percent, mean intensity 167.7, 23/23 frames in motion).

02Grid dot halftone — letters made of sample spacing

Grid dot halftone — letters made of sample spacing
Open in a new tab

02 · Grid dot halftone — letters made of sample spacing — Measured area 36.418% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDataarcrequestAnimationFrame

Ink density rewritten as dot size at every grid point

The alpha mask is read only at fixed grid intervals with dot radius scaled by the sampled value, and the interval itself tightens and loosens over time so the letters densify and rarify like a printing plate — a natural fit for print and publishing concept heroes.

// sample interval drifts 4.2↔7.8px over a 2s cycle — the plate densifies and rarifies
function stepAt(t) { return 6 + 1.8 * Math.sin(t * Math.PI); }
function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH, s = stepAt(t);
  var ox = Math.sin(t * 1.2) * 4.0, oy = Math.cos(t * 0.9) * 3.2;
  var rows = Math.ceil((SH + 8) / s) + 2, colsN = Math.ceil((SW + 8) / s) + 2;
  for (var gy = -2; gy < rows; gy++) {
    var y = gy * s + oy;
    for (var gx = -2; gx < colsN; gx++) {
      var x = gx * s + ((gy & 1) ? s * 0.5 : 0) + ox;  // every other row offset half a cell
      var idx = ((gy + 8) * 131 + (gx + 8)) % phas.length; // per-gridpoint phase
      var jx = x + Math.sin(t * 5.1 + phas[idx]) * 0.9;
      var jy = y + Math.cos(t * 4.7 + phas[idx] * 1.3) * 0.9;
      var xi = Math.round(jx), yi = Math.round(jy);
      if (xi < 0 || yi < 0 || xi >= SW || yi >= SH) continue;
      var a = mask[(yi * SW + xi) * 4 + 3] / 255;  // ink density of this cell
      var r = (0.17 + Math.pow(a, 1.2) * 0.86) * s * 0.52;
      ctx.globalAlpha = 0.30 + a * 0.70;
      ctx.fillStyle = cols[a > 0.66 ? 0 : (a > 0.4 ? 1 : 2)];
      ctx.beginPath(); ctx.arc(jx * sx, jy * sy, r * sx, 0, 6.2832); ctx.fill();
    }
  }
  ctx.globalAlpha = 1;
}

What halftone reading keeps on the page

Because the interval slides smoothly, the dots would otherwise surf across the grid, so each one carries a seeded micro jitter of ±0.9px and lands slightly off its exact cell every frame while still holding its place. Letters rise exactly where grid points read high ink, going dense or sparse as the stride breathes between 4.2 and 7.8px on a 2s cycle, and even empty cells receive a faint ground dot so the whole plate breathes like paper (measured: cumulative area 36.42 percent, 23/23 frames in motion).

03Line segment mesh — threads between neighbor samples

Line segment mesh — threads between neighbor samples
Open in a new tab

03 · Line segment mesh — threads between neighbor samples — Measured area 30.981% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

moveTolineTogetImageData

Only the near neighbor pairs become threads

Sampled grid points pick only their near neighbor pairs and get stitched with moveTo and lineTo, each thread's alpha fluttering with its value until the letter silhouette rises out of the weave — a background treatment that suits tech and AI startup heroes.

function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH, wx = SW * (0.5 + 0.46 * Math.sin(t * 3.9)), wy = SH * (0.5 + 0.44 * Math.cos(t * 3.1));
  ctx.lineCap = 'round';
  for (var i = 0; i < pts.length; i++) {
    var p = pts[i];
    var nbs = [grid[gkey(p.gx + 1, p.gy)], grid[gkey(p.gx, p.gy + 1)],
               grid[gkey(p.gx + 1, p.gy + 1)], grid[gkey(p.gx + 1, p.gy - 1)]];
    for (var k = 0; k < nbs.length; k++) {
      var j = nbs[k];
      if (j === undefined || j <= i) continue;
      var q = pts[j], dx = q.x - p.x, dy = q.y - p.y;
      if (dx * dx + dy * dy > LINK * LINK) continue;
      var mx = (p.x + q.x) * 0.5, my = (p.y + q.y) * 0.5;
      var d = Math.sqrt((mx - wx) * (mx - wx) + (my - wy) * (my - wy));
      var al = p.a * q.a * (0.26 + 0.74 * Math.max(0, Math.cos(d * 0.11 - t * 2.6)));  // bright only under the crest
      if (al < 0.05) continue;
      ctx.strokeStyle = al > 0.62 ? colA : (al > 0.32 ? colB : colC);
      ctx.globalAlpha = Math.min(1, 0.2 + al);
      ctx.lineWidth = (1.8 + al * 3.4) * sx;
      ctx.beginPath();
      ctx.moveTo(p.x * sx, p.y * sy); ctx.lineTo(q.x * sx, q.y * sy); ctx.stroke();
    }
  }
}

A wave crest waking the silhouette

Checking only the right, lower, and two diagonal neighbors means no thread is ever drawn twice, and indexing the candidates through grid coordinates skips any distance sort entirely. The wave origin travels a large loop around the board, so threads brighten and thicken from a base width of 1.8px only where the crest passes, and the letters switch on in a sweep instead of all at once (measured: cumulative area 30.98 percent, 23/23 frames in motion).

04Circle packing — most circles inside the mask

Circle packing — most circles inside the mask
Open in a new tab

04 · Circle packing — most circles inside the mask — Measured area 32.033% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arcgetImageDatafillText

Shrinking diameters, overlaps discarded

Circles are dropped at random inside the mask at shrinking diameters with every overlap thrown away — a greedy fill finished with a gentle pulse, and because the seed is fixed, the same word always gets the same packing, which makes it a dependable choice for an event or keynote slide title.

function packCircles(data, seed) {
  var rnd = mulberry32(seed), out = [], grid = [];   // cell → circle index list
  function hits(x, y, r) { var cx = Math.floor(x / CELL), cy = Math.floor(y / CELL);
    for (var gy = cy - 1; gy <= cy + 1; gy++) {
      for (var gx = cx - 1; gx <= cx + 1; gx++) {
        if (gx < 0 || gy < 0 || gx >= GW || !grid[gy * GW + gx]) continue;
        var cell = grid[gy * GW + gx];
        for (var k = 0; k < cell.length; k++) {
          var c = out[cell[k]], dx = c.x - x, dy = c.y - y, rr = c.r + r + 0.25;
          if (dx * dx + dy * dy < rr * rr) return true;
        }
      }
    }
    return false;
  }
  for (var d = 0; d < DIAMS.length; d++) { var r = DIAMS[d] / 2;
    for (var i = 0; i < TRIES[d]; i++) {
      var x = rnd() * SW, y = rnd() * SH;
      if (data[((y | 0) * SW + (x | 0)) * 4 + 3] <= 128 || hits(x, y, r)) continue;  // outside mask or overlapping
      var gi = (y / CELL | 0) * GW + (x / CELL | 0);
      (grid[gi] || (grid[gi] = [])).push(out.length); out.push({ x: x, y: y, r: r, ph: rnd() * 6.2832, ci: pickColor(rnd()) });
    }
  }
  return out;
}

The same seed guarantees the same layout

Stepping the diameter down through eight stages from 9.2px to 1.4px fills the thick strokes with big circles first and leaves the thin stroke ends to the small ones. Collision lookups go through a 12px spatial hash so only the nine neighboring cells are ever tested, and since mulberry32(seed) decides every trial order, typing seed 7 twice returns the identical arrangement while 91 redraws it completely (measured: cumulative area 32.03 percent, mean intensity 167.9, 23/23 frames in motion).

05Glitch shards — letters slipping in bands

Glitch shards — letters slipping in bands
Open in a new tab

05 · Glitch shards — letters slipping in bands — Measured area 37.567% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

drawImagegetImageDatarequestAnimationFrame

Rows torn off and re-stamped at new offsets

Each sampled horizontal band becomes an independent shard re-stamped with drawImage at its own offset, so rows jump on their own timing and the word tears apart and re-joins — the texture of game and hacking concept error screens.

function offsetAt(row, t) {
  var nz = rowNoise[row];
  var o = Math.sin(t * 2.1 * nz.sp + nz.n1) * 4.6 + Math.sin(t * 3.7 * nz.sp + nz.n2) * 2.2;
  var ph = (t % KICK) / KICK;                        // kick — half the rows jump hard inside a 0.09s window
  if (ph < 0.09 && (row * 2654435761 + seed) % 2 === 0) o += (nz.n1 > 3.14 ? 1 : -1) * 16 * Math.sin((ph / 0.09) * Math.PI);
  return o;
}
function paint(t) {
  ctx.clearRect(0, 0, W, H);
  if (!ink) return;
  var sx = W / SW, sy = H / SH;
  ctx.imageSmoothingEnabled = false;
  for (var row = 0; row < bands; row++) {
    var o = offsetAt(row, t), y = row * BH, aa = Math.abs(o);
    ctx.drawImage(ink, 0, y, SW, BH, o * sx, y * sy, SW * sx, BH * sy + 0.5);
    if (aa > 4.5) {                                  // ghosts only on large slips ±2.2px
      ctx.globalAlpha = Math.min((aa - 4.5) / 8, 1) * 0.6;
      ctx.drawImage(ink, 0, y, SW, BH, (o - 2.2) * sx, y * sy, SW * sx, BH * sy + 0.5);
      ctx.drawImage(ink, 0, y, SW, BH, (o + 2.2) * sx, y * sy, SW * sx, BH * sy + 0.5);
      ctx.globalAlpha = 1;
    }
  }
  ctx.imageSmoothingEnabled = true;
}

Ghosting only when the slip is large

The mask is pre-rendered once onto an offscreen ink plate, so tearing a row means stamping that slice of the plate at a new offset and not one pixel of it is ever recomputed — thirty-six 3px rows can ride along every frame without the cost stacking up. Ghost copies ±2.2px appear only above a 4.5px slip, and a 0.09s kick window shoves half the rows by 16px, keeping the tear rare and violent rather than constantly jittering (measured: cumulative area 37.57 percent, 23/23 frames in motion).

06Noise warp — letters washed by ripples

Noise warp — letters washed by ripples
Open in a new tab

06 · Noise warp — letters washed by ripples — Measured area 30.59% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDataarcrequestAnimationFrame

The target points themselves get pushed into waves

Two layered sine phases shift the target points as they are read, and the particles chasing those wobbling targets make the whole word shimmer like heat haze — a backdrop suited to music and festival landings.

function paint(t) {
  ctx.clearRect(0, 0, W, H);
  var sx = W / SW, sy = H / SH;
  for (var i = 0; i < parts.length; i++) {
    var p = parts[i];
    // slow sway — a big sine, phase offset per row and column, the whole word staggers
    var swx = Math.sin(t * 0.95 + p.oy * 0.028) * 19;
    var swy = Math.cos(t * 0.75 + p.ox * 0.031) * 13;
    // ripple — the target itself waves; amplitude slowly swells and sinks too
    var amp = AMP * (1 + 0.85 * Math.sin(t * 2.4 + p.ox * 0.02 + p.oy * 0.015));
    var tx = p.ox + swx + Math.sin(p.oy * F1 + t * 2.0) * Math.sin(p.ox * F2 - t * 1.4) * amp;
    var ty = p.oy + swy + Math.cos(p.ox * F2 * 1.15 + t * 1.7) * Math.sin(p.oy * F1 * 0.9 + t * 1.1) * amp * 0.72;
    p.x += (tx - p.x) * LERP;                       // lerp chase — the afterimage magnifies the shimmer
    p.y += (ty - p.y) * LERP;
    ctx.fillStyle = cols[p.ci];
    ctx.beginPath();
    ctx.arc(p.x * sx, p.y * sy, p.sz * sx * 0.85, 0, 6.2832);
    ctx.fill();
  }
}

A product of two sines making the grain of the water

One sine would just rock the word side to side, but multiplying a vertical carrier by a horizontal one — sin(y·F1+t) × sin(x·F2−t·0.7) — reverses the push wherever crest meets trough, and the interference reads as water grain instead of sway. The amplitude itself swells and sinks on a slow sine so no two waves match, and the particles chase their moving targets at LERP 0.14, leaving afterimages where the target has already traveled on (measured: cumulative area 30.59 percent with mean intensity 151.0 — the narrowest sweep of the nine, and one of the shakiest).

07Morphing — one word becoming another

Morphing — one word becoming another
Open in a new tab

07 · Morphing — one word becoming another — Measured area 41.546% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillTextgetImageDataarc

Two target sets built, then swapped mid-flight

Both words are sampled into two target sets, particles are paired in order up to the larger count, and swapping the targets makes one word flow into the other — the rolling value-proposition move, and the widest-moving board of the nine.

// 3) sample both words, particle count matched to the larger set
function resample(nextWord, nextSeed) {
  var w = String(nextWord == null ? wordB : nextWord).trim().toUpperCase();
  if (w) wordB = w;                          // empty string keeps the stored value
  var s = Math.floor(Number(nextSeed));
  if (s >= 1) seed = s;
  var setA = samplePoints(buildMask(WORD_A));
  var setB = samplePoints(buildMask(wordB));
  var n = Math.max(setA.length, setB.length);  // particle count = larger set
  var rnd = mulberry32(seed);
  parts = [];
  for (var i = 0; i < n; i++) {
    var a = setA[i % setA.length], b = setB[i % setB.length];
    var r1 = rnd(), r2 = rnd(), r3 = rnd(), r4 = rnd();
    parts.push({
      x: a.x, y: a.y,                        // start = first word's shape
      ax: a.x, ay: a.y,                      // target A — HELLO-side coordinates
      bx: b.x, by: b.y,                      // target B — typed word's coordinates
      sz: 2.9 + r1 * 2.2, ph: r2 * 6.2832, ci: pickColor(r3),
      bl: (r4 - 0.5) * 26                    // arc bow (px) — bends the path into an arc
    });
  }
}

Coordinates flow, so no ghost frames exist

// morph mix — sine round trip 0..1..0, two loops inside the 2s gif
var mix = 0.5 - 0.5 * Math.cos(t / CYCLE * 6.2832);
var ease = mix * mix * (3 - 2 * mix);               // smoothstep
for (var i = 0; i < parts.length; i++) {
  var p = parts[i];
  var dx = p.bx - p.ax, dy = p.by - p.ay;
  var tx = p.ax + dx * ease, ty = p.ay + dy * ease;
  var bow = Math.sin(ease * Math.PI);               // zero at the ends, max in the middle
  tx += -dy * 0.35 * bow + p.bl * bow;              // orthogonal arc spread
  ty +=  dx * 0.35 * bow + p.bl * 0.6 * bow;
  tx += Math.sin(t * 0.95 + p.ay * 0.028) * 12;     // slow sway (the main source of area)
  ty += Math.cos(t * 0.75 + p.ax * 0.031) * 9;
  var ob = 0.55 + 0.45 * Math.sin(t * 1.9 + p.ph * 3.1);   // breathing orbit size
  tx += Math.cos(t * 1.35 + p.ph) * ORBIT * ob;
  ty += Math.sin(t * 1.15 + p.ph * 1.7) * ORBIT * ob * 0.8;
  p.x += (tx - p.x) * LERP;                         // lerp chase
  p.y += (ty - p.y) * LERP;
}

Cross-fading the two words' alpha would drag a half-and-half ghost frame through the middle, but coordinate interpolation holds every particle at exactly one point per instant, with the sparser target set recycled through modulo pairing onto the same particles. An orthogonal bow of 0.35 plus each particle's own arc spread bends the flight into a wide sweep — the move a particle text animation after effects template must pre-render as a fixed path, recomputed here live from any typed pair — and a slow sway of 12 and 9px makes this the widest traveler of the nine (measured: cumulative area 41.55 percent, 23/23 frames in motion).

08ASCII density — brightness choosing glyphs

ASCII density — brightness choosing glyphs
Open in a new tab

08 · ASCII density — brightness choosing glyphs — Measured area 32.283% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fillTextgetImageDatarequestAnimationFrame

Cell alpha averaged into a density reading

Each grid cell's alpha is averaged into a density that picks one glyph from the ladder " .:-=+*#%@" stamped back with fillText, and sliding the ladder over time changes the order the board fills in — the native look of retro terminal and developer landings.

// 2) mask → cell density grid
function buildCells(word) {
  var data = buildMask(word);
  var cw = Math.floor(SW / CELL), ch = Math.floor(SH / CELL);
  var rnd = mulberry32(seed);
  var out = [];
  for (var cy = 0; cy < ch; cy++) {
    for (var cx = 0; cx < cw; cx++) {
      var sum = 0, n = 0;
      for (var py = cy * CELL; py < (cy + 1) * CELL; py++) {
        for (var px = cx * CELL; px < (cx + 1) * CELL; px++) {
          sum += data[(py * SW + px) * 4 + 3]; n++;
        }
      }
      var d = sum / (n * 255);                   // 0..1 density
      var r1 = rnd(), r2 = rnd();
      out.push({
        cx: cx, cy: cy, d: d,
        ci: d > 0.02 ? (r1 < 0.6 ? 0 : r1 < 0.78 ? 1 : r1 < 0.9 ? 2 : 3) : 0,
        ph: r2 * 6.2832                          // scan phase — the seed decides
      });
    }
  }
  return out;
}

Sliding the ladder changes the fill order

// ladder slide — the density→glyph mapping drifts over time (entrance sequence)
var slide = (t / CYCLE) * LADDER.length;
for (var i = 0; i < cells.length; i++) {
  var c = cells[i];
  var bg = c.d <= 0.02;                    // cell outside the mask
  if (bg && slBg <= 0) continue;
  var win = Math.max(0, Math.cos(Math.min(t * 2.6, Math.PI)));  // entrance window
  var sl = slide * 0.28 * win + Math.sin(t * 5.3 + c.ph) * (LADDER.length + 1.5);
  var lv = (bg ? 0 : c.d) * (LADDER.length - 1) + (bg ? slBg : sl);
  var idx = Math.max(0, Math.min(LADDER.length - 1, Math.round(lv)));
  var ch = LADDER.charAt(idx);
  if (ch === ' ') continue;
  ctx.fillText(ch, (c.cx + 0.5) * CELL * sx, (c.cy + 0.5) * CELL * sy);
}

Reading each cell as a plain in-or-out threshold flattened the word into a cut stamp, so the fill averages every pixel's alpha inside the cell instead, and half-covered edge cells receive faint dots and colons while solid stroke centers earn the heavy # and @. The ladder's reference point is pushed along by time so identical densities resolve to different glyphs as the sequence slides, and a per-cell scan phase inherited from the seed keeps the fill crawling cell by cell (measured: cumulative area 32.28 percent, 23/23 frames in motion).

09Scatter and reassemble — letters reforming around the pointer

Scatter and reassemble — letters reforming around the pointer
Open in a new tab

09 · Scatter and reassemble — letters reforming around the pointer — Measured area 35.76% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDataarcrequestAnimationFrame

The tug of war between restore spring and push

Every particle keeps a restoring force toward its target pixel while the pointer's push wins only inside its radius, so letters displaced by the hand re-assemble back into place — the interactive hero or exhibition installation piece.

function step(dt) {
  // virtual pointer — with no real pointer, a sine sweeps across the board (auto mode)
  var px, py;
  if (ptr.on) {
    px = ptr.x; py = ptr.y;
  } else {
    var w = tNow * 0.9;
    px = SW / 2 + Math.sin(w) * (SW * 0.36);
    py = SH / 2 + Math.sin(w * 2.3 + 1.2) * (SH * 0.30);
  }
  for (var i = 0; i < parts.length; i++) {
    var p = parts[i];
    var dx = p.x - px, dy = p.y - py;
    var d2 = dx * dx + dy * dy;
    var R = ptr.on ? RADIUS : AUTO_R;                // auto mode sweeps wider
    if (d2 < R * R && d2 > 0.01) {
      var d = Math.sqrt(d2), f = (1 - d / R) * PUSH;  // pushes harder when closer
      p.vx += (dx / d) * f; p.vy += (dy / d) * f;
    }
    p.vx += (p.ox - p.x) * SPRING;                   // restore toward target
    p.vy += (p.oy - p.y) * SPRING;
    p.vx *= DAMP; p.vy *= DAMP;
    p.x += p.vx; p.y += p.vy;
  }
}

Target restoration, not an emitter

Canvas particle scenes usually reach for a firework emitter and a lifetime axis, and this board deliberately carries neither — particles are never born and never die, they simply remember the pixel they were sampled from and return home through a SPRING of 0.012 the moment the push fades. With no pointer present a virtual one sweeps the board on a pair of sines at a wider radius, so the word shreds and heals itself even untouched (measured: cumulative area 35.76 percent, mean intensity 176.2, 23/23 frames in motion).

How does sampling turn letters into pixels? — getImageData

One invisible canvas, read straight back

Every board repeats the same opening move: a 252x108 offscreen canvas receives the word in white through fillText, and wherever ink lands the alpha byte sits near 255 while empty space stays at 0 — so the fourth byte of each pixel alone is a numeric map of the glyph shapes. getImageData carries that array out of the canvas, and everything downstream is arithmetic on those numbers.

Six fork-roads after the read

What happens next is the only place the boards differ. Coordinates where alpha clears 128 become a target list for particles (01, 06, 07, 09); values read at grid points become dot sizes (02); neighbor pairs become threads (03); circles packed inside the mask become the typography itself (04); rows torn off the ink plate become shards (05); and cell-by-cell averages become density (08). One mask, six readings, nine boards.

No library, no font download

The whole chain runs on the system font and canvas 2D that ship with the browser — no particle library, no webfont fetch, no build step. That re-reading of already-drawn letters as raw material is what separates this axis from the noise terrain approach of computing texture from a noise function or the raymarching approach of tracing rays into a 3D field.

Where it breaks — the trap

Dots that surfed the grid

The first halftone build pushed the dot interval along a sine, and the dots slid across the grid like beads on a net — the word read as a mesh, not a printing plate. Grid coordinates were changing continuously frame to frame, so each dot flowed into the neighboring cell instead of growing where it stood. Giving every dot its own seeded micro jitter and drawing it slightly off its exact cell each frame fixed the slide: the dots hold their positions and still look alive.

The stamp that lost its shading

The ASCII board broke in the opposite direction. Reading each cell as a binary threshold — ink or no ink — turned the word into a flat cut stamp with no interior shading at all, and only after the read was changed to average every pixel's alpha inside the cell did the faint ladder of dots and colons appear along the edges.

The fallback font flash

One more trap lives in fillText itself: drawn before the intended font has loaded, it stretches the word in a fallback face, and the moment the real face arrives the mask is re-sampled and the target count jumps by hundreds — the particles visibly bounce. The boards now use only the system Arial and recompute every target from scratch on each word change, so the jump never has a chance to appear. Everything — the vanilla source of all nine boards plus the React ports — ships in the locked archive below this article, and the key that opens it is ex82wzp9.

Three variants and accessibility

Knob Change What it does
Sample interval 02 grid dots, 6px to 3px a sparse dot-word densifies into print-grade texture
The word 07 morph, HELLO to WELCOME the same particles flow into different letters
Color $glyph-sky to $glyph-peach cold cyan particles turn a warm peach

Sample interval

Halving the halftone stride from 6px to 3px multiplies the grid points per row and the dots tighten into an offset-print texture — the same word, read at double the resolution.

The word

The morph board resamples whatever sits in its input field, so swapping HELLO for WELCOME sends the identical particle cloud flowing into a new set of targets with no rebuild visible to the eye.

Color

Each board reads its palette from pastel token variables at the top of its scss, and trading $glyph-sky for $glyph-peach re-tints the cold cyan particles warm without touching a line of the sampling math.

What reduced-motion keeps

Under prefers-reduced-motion the animation loop never starts, and each board draws the single frame its seed chose — 01 a settled word with every particle home, 02 a fully inked halftone plate, 08 a terminal page with the ladder landed. The word and seed inputs stay live in that still frame, so typing a new value redraws one different frame instantly: the motion stops, the letters remain.

FAQ

Does the input field resample the word live?

Yes. All nine boards listen for input events on the word and seed fields and rerun the whole chain from buildMask down, recomputing every target on the spot. In the render probes the default word GLYPH resampled from 1631 target points down to 856 for a longer word — the auto-fit shrinks the glyphs to stay inside the frame, so fewer cells clear the stride — and the morph board's WAVE went from 2063 targets to 1110. The same seed typed twice reproduces the identical arrangement and seed 7 versus 91 draws a different one, which is most visible on the circle packing and the morph.

When is a text animation css keyframe enough, and when do I need canvas?

While the letter moves as one rigid shape — sliding, fading, scaling — CSS keyframes and transitions remain the lighter and more precise tool. The canvas earns its place the moment the letter's interior becomes the material: particles scattering out of pixels, halftone dots densifying, one word flowing into another all require the pixel map inside the glyphs that CSS never sees.

Can I paste the source straight into a project?

Yes — both the vanilla source and the React port of every board live inside the locked archive, and each file pastes into a page and runs as-is. Colors swap through the pastel token variables at the top of each scss, and the word and seed defaults sit in plain sight near the top of the source where the demo's own inputs read them.

Further reading

Enter the archive password

The password is inside this article. You will find it as you read.