Aduok Code

Sand Text Animation with Canvas & Vanilla JS

Introduction

This article builds a looping sand-text effect on a single fullscreen canvas — a word made of thousands of tiny grains slowly erodes from its edges inward, the loosened grains fall under gravity and drift, they pile up and slump like real sand, and then the entire pile launches back into the air and re-forms the word again. The cycle repeats forever. No images, no DOM nodes per particle, no library — just the Canvas 2D API, two Uint8Array bitmasks, and a small state machine driving everything from one requestAnimationFrame loop.

The effect rests on four mechanisms working together: an offscreen canvas used purely as a stencil — its only job is letting us read which grid cells a rendered word's glyphs cover, via getImageData alpha sampling — two Uint8Array bitmasks (wordMask and pileMask) that double as both the physics state and the only thing the renderer ever draws — a five-state phase machine that decides, frame by frame, whether grains are eroding, falling, resting, or flying back into formation — and small plain-object "grains" with velocity or eased travel data that exist only while they're airborne.

What you will learn

How to sample a rendered word into grid cells using an offscreen canvas and alpha thresholding — how to represent thousands of particles as two flat bitmasks instead of thousands of objects — how to design a finite phase machine that cleanly sequences erosion, falling, piling, and reform — how to fake a believable sand pile with a simple slumping/erosion pass — and how to animate discrete grains along eased, wobbling flight paths back to a target shape.

01
Part One
Architecture

Step 1 — A Five-Phase State Machine

Everything lives inside one SandTextScene class: the canvas, grid metadata, the two bitmasks, the grain arrays, and the current phase are all private fields. A PHASE enum replaces magic strings, and #updatePhase() is a single switch statement that dispatches to exactly one update method per frame depending on which of the five phases the scene is in. Because the whole lifecycle is just RELEASING → FALLING → PILED → REFORMING → FORMED → back to RELEASING, resize() can safely reset to PHASE.RELEASING at any point without leaving stray state behind.

JS
const CONFIG = {
  word: "ADUOK",
  grainSize: 3,             // px size of one grid cell / grain
  releaseSamplesPerFrame: 1500,
  releaseChance: 0.022,
  edgeReleaseMultiplier: 3.3,

  gravity: 850,
  airDrag: 0.992,

  pileRelaxStepsPerFrame: 5,
  pileHoldSeconds: 0.8,

  reformDurationSeconds: 2,
  reformStaggerSeconds: 0.65,
  formedHoldSeconds: 3
};

// word sits formed -> grains erode -> fall -> pile up ->
// pile briefly settles -> pile flies back into the word -> holds -> repeat
const PHASE = {
  RELEASING: "releasing",
  FALLING:   "falling",
  PILED:     "piled",
  REFORMING: "reforming",
  FORMED:    "formed"
};
PhaseWhat is happeningExits when
RELEASINGWord sits static; a shuffled queue of its cells is sampled each frame and randomly converted into falling grainsThe release queue is empty
FALLINGNo more cells left to erode; existing grains are still in the airfallingGrains.length reaches 0
PILEDAll grains have settled into the pile; the pile relaxes for a short holdphaseElapsed ≥ pileHoldSeconds
REFORMINGPile cells fly back up to word target cells along eased, wobbling pathsEvery reforming grain reaches t = 1
FORMEDWord is fully solid again and holds before the next erosion beginsphaseElapsed ≥ formedHoldSeconds
02
Part Two
Canvas, Grid & DPI

Step 2 — Mapping the Canvas to a Particle Grid

The canvas is never addressed per-pixel for physics — only a coarse grid of grainSize-px cells is. columns and rows are derived from window size divided by grainSize, and every bitmask is sized columns × rows. A single helper trio — #cellIndex(col, row), #columnOf(index), #rowOf(index) — converts between 2D grid coordinates and the flat array index every other method uses, which keeps the rest of the class free of any manual row * columns + col arithmetic. devicePixelRatio is capped at 2 and applied once via ctx.setTransform so the rest of the code can keep working entirely in CSS pixels.

JS
#handleResize() {
  this.#width  = window.innerWidth;
  this.#height = window.innerHeight;

  this.#canvas.width  = this.#width  * this.#pixelRatio;
  this.#canvas.height = this.#height * this.#pixelRatio;
  this.#canvas.style.width  = this.#width  + "px";
  this.#canvas.style.height = this.#height + "px";

  this.#ctx.setTransform(this.#pixelRatio, 0, 0, this.#pixelRatio, 0, 0);

  this.#columns = Math.ceil(this.#width  / CONFIG.grainSize);
  this.#rows    = Math.ceil(this.#height / CONFIG.grainSize);

  this.#wordMask = new Uint8Array(this.#columns * this.#rows);
  this.#pileMask = new Uint8Array(this.#columns * this.#rows);

  this.#buildWordCells();
  this.#beginReleasePhase();
}

#cellIndex(col, row) { return row * this.#columns + col; }
#columnOf(cellIndex)  { return cellIndex % this.#columns; }
#rowOf(cellIndex)     { return Math.floor(cellIndex / this.#columns); }
03
Part Three
Sampling the Word

Step 3 — Turning a Rendered Word into Grid Cells

A second, invisible canvas is created purely as a stencil: the word is drawn onto it once, large and bold, then getImageData reads back every pixel's alpha channel. For every grid cell, the code samples the alpha value at that cell's center pixel — if it is above a small threshold, the cell is considered part of the glyph and its index is pushed into wordCells. This means the browser's own font rasterizer does all the hard work of turning a string into a shape; the simulation only ever deals with a flat list of grid indices afterward.

JS
#buildWordCells() {
  const maskCanvas = document.createElement("canvas");
  const maskCtx     = maskCanvas.getContext("2d");

  maskCanvas.width  = this.#width;
  maskCanvas.height = this.#height;

  const fontSize = Math.min(this.#width * 0.22, this.#height * 0.28, 190);

  maskCtx.fillStyle    = "#fff";
  maskCtx.textAlign    = "center";
  maskCtx.textBaseline = "middle";
  maskCtx.font = "900 " + fontSize + "px system-ui, sans-serif";
  maskCtx.fillText(CONFIG.word, this.#width / 2, this.#height * 0.34);

  const pixels = maskCtx.getImageData(0, 0, this.#width, this.#height).data;
  this.#wordCells = [];

  for (let row = 0; row < this.#rows; row++) {
    for (let col = 0; col < this.#columns; col++) {
      const x = Math.floor(col * CONFIG.grainSize + CONFIG.grainSize / 2);
      const y = Math.floor(row * CONFIG.grainSize + CONFIG.grainSize / 2);
      const alpha = pixels[(y * this.#width + x) * 4 + 3];

      if (alpha > 35) this.#wordCells.push(this.#cellIndex(col, row));
    }
  }
}
Why sample a canvas instead of parsing the font?

Letting the browser render the text means any installed system font, weight, or even emoji renders correctly with zero font-parsing code. The simulation does not care that it is text at all — it only ever sees "these grid cells are filled", so the exact same pipeline could sample a logo, an SVG path, or a photo silhouette instead of a word.

04
Part Four
Releasing Grains

Step 4 — Eroding the Word One Grain at a Time

beginReleasePhase() fills wordMask from wordCells and builds releaseQueue as a Fisher–Yates-shuffled copy of the same array. Each frame, up to releaseSamplesPerFrame random entries are pulled from that queue. A cell sitting on the outer edge of the glyph — nothing filled below it or to either side — gets its release chance multiplied by edgeReleaseMultiplier, so the word visibly crumbles from its outline inward rather than dissolving into a uniform haze. A released cell is cleared from wordMask and becomes a falling grain with randomized velocity and a drift target it will wander toward.

JS
#updateReleasePhase() {
  if (this.#releaseQueue.length === 0) {
    this.#phase = PHASE.FALLING;
    this.#phaseElapsed = 0;
    return;
  }

  for (let i = 0; i < CONFIG.releaseSamplesPerFrame; i++) {
    if (this.#releaseQueue.length === 0) break;

    const queueIndex = randomIntBetween(0, this.#releaseQueue.length - 1);
    const cellIndex  = this.#releaseQueue[queueIndex];
    if (this.#wordMask[cellIndex] === 0) {
      this.#releaseQueue.splice(queueIndex, 1);
      continue;
    }

    const col = this.#columnOf(cellIndex);
    const row = this.#rowOf(cellIndex);

    const isAtBottomEdge = row >= this.#rows - 1 ||
      this.#wordMask[this.#cellIndex(col, row + 1)] === 0;
    const isAtSideEdge = col <= 0 || col >= this.#columns - 1 ||
      this.#wordMask[this.#cellIndex(col - 1, row)] === 0 ||
      this.#wordMask[this.#cellIndex(col + 1, row)] === 0;

    const multiplier = (isAtBottomEdge || isAtSideEdge)
      ? CONFIG.edgeReleaseMultiplier : 1;

    if (Math.random() < CONFIG.releaseChance * multiplier) {
      this.#releaseGrain(cellIndex);
      this.#releaseQueue.splice(queueIndex, 1);
    }
  }
}

Sampling at random instead of scanning sequentially, combined with the edge multiplier, is what makes the erosion look organic. A sequential scan would crumble the word row by row like a progress bar; weighted random sampling makes loose grains peel away unevenly from every edge at once, the way a real sandcastle erodes.

05
Part Five
Falling & the Pile

Step 5 — Gravity, Drift & Settling into the Pile

Each falling grain is a plain object carrying position, velocity, and a drift value that retargets itself every fraction of a second for an organic, non-linear sideways wander. Every frame applies gravity to vy, light air drag to both velocity components, then integrates position. Once a grain's next row would land on the floor or on an already-solid pileMask cell, #settleGrainIntoPile() looks for a free spot directly below it, then to the left, then to the right, and finally scans straight up the same column — the same order a real grain would naturally find a resting point.

JS
#updateFallingGrains(dt) {
  for (let i = this.#fallingGrains.length - 1; i >= 0; i--) {
    const grain = this.#fallingGrains[i];

    grain.driftTimer -= dt;
    if (grain.driftTimer <= 0) {
      grain.driftTarget = randomBetween(-85, 85);
      grain.driftTimer  = randomBetween(0.25, 1.2);
    }
    grain.drift += (grain.driftTarget - grain.drift) * dt * 2;

    grain.vx += grain.drift * dt;
    grain.vy += CONFIG.gravity * dt;
    grain.vx *= CONFIG.airDrag;
    grain.vy *= CONFIG.airDrag;

    grain.x += grain.vx * dt;
    grain.y += grain.vy * dt;

    const col     = Math.floor(grain.x / CONFIG.grainSize);
    const nextRow = Math.floor((grain.y + CONFIG.grainSize) / CONFIG.grainSize);

    if (nextRow >= this.#rows || this.#isPileSolid(col, nextRow)) {
      this.#settleGrainIntoPile(grain);
      this.#fallingGrains.splice(i, 1);
    }
  }

  if (this.#phase === PHASE.FALLING && this.#fallingGrains.length === 0) {
    this.#phase = PHASE.PILED;
    this.#phaseElapsed = 0;
  }
}
Grain fieldTypePurpose
x / ynumberCurrent pixel position, integrated every frame from velocity
vx / vynumberVelocity in px/s — vy accumulates gravity, vx is pushed by drift
driftnumberCurrent sideways force, eased toward driftTarget each frame
driftTargetnumberA new random sideways force the grain is currently easing toward
driftTimernumberCountdown until a fresh driftTarget is chosen, for organic wander
06
Part Six
Pile Relaxation

Step 6 — Slumping the Pile Like Real Sand

#relaxPile() runs several times per frame and alternates scanning the grid left-to-right or right-to-left each pass to avoid a directional bias. For every occupied pileMask cell it first tries to drop straight down; if that is blocked, it picks a random preferred diagonal — left or right — and tries that before the other. Repeated every frame, this simple two-rule pass is enough to make freshly dropped grains slide off the top of a peak and spread into a naturally sloped pile instead of stacking into unrealistic vertical columns.

JS
#relaxPileCell(col, row) {
  const current = this.#cellIndex(col, row);
  if (this.#pileMask[current] !== 1) return;

  if (!this.#isPileSolid(col, row + 1)) {
    this.#pileMask[this.#cellIndex(col, row + 1)] = 1;
    this.#pileMask[current] = 0;
    return;
  }

  const preferLeft = Math.random() > 0.5;
  const firstCol  = preferLeft ? col - 1 : col + 1;
  const secondCol = preferLeft ? col + 1 : col - 1;

  if (!this.#isPileSolid(firstCol, row + 1)) {
    this.#pileMask[this.#cellIndex(firstCol, row + 1)] = 1;
    this.#pileMask[current] = 0;
    return;
  }
  if (!this.#isPileSolid(secondCol, row + 1)) {
    this.#pileMask[this.#cellIndex(secondCol, row + 1)] = 1;
    this.#pileMask[current] = 0;
  }
}
07
Part Seven
Reforming the Word

Step 7 — Flying Grains Back into Formation

#beginReform() collects every occupied pileMask cell and every target wordCells index, sorts both lists bottom row first, and pairs them up by position in the sorted lists — so grains resting lowest in the pile tend to feed the lowest letters first, giving the rebuild a coherent bottom-up flow instead of a chaotic scramble. Each pair becomes a "reforming grain" with a random start delay (so not every grain launches in the same instant), an eased travel duration, and a sine-based arc and wobble layered on top of a straight lerp — turning what would otherwise be a flat slide into something that reads as a grain leaping and curving through the air.

JS
#updateReform() {
  let allArrived = true;

  for (const grain of this.#reformingGrains) {
    const localTime = this.#phaseElapsed - grain.delay;
    if (localTime <= 0) { grain.x = grain.sx; grain.y = grain.sy; allArrived = false; continue; }

    const t      = clamp01(localTime / grain.duration);
    const eased  = easeInOutCubic(t);
    const arc    = Math.sin(eased * Math.PI);
    const wobble = Math.sin(eased * Math.PI * 2 + grain.wobblePhase) * grain.wave * arc;

    grain.x = grain.sx + (grain.tx - grain.sx) * eased + wobble;
    grain.y = grain.sy + (grain.ty - grain.sy) * eased - arc * this.#height * 0.08;

    if (t < 1) allArrived = false;
  }

  if (allArrived) {
    for (const cellIndex of this.#wordCells) this.#wordMask[cellIndex] = 1;
    this.#reformingGrains = [];
    this.#phase = PHASE.FORMED;
    this.#phaseElapsed = 0;
  }
}

arc peaks at the midpoint of the flight (sin of an eased 0→π) and is used twice: once to lift the grain's y position into a small hop, and once to scale the sideways wobble so it is strongest mid-flight and settles to zero exactly as the grain arrives — without that second multiplication the grain would visibly snap sideways right as it lands.

Tuning Reference

ConstantDefaultEffect
grainSize3pxSize of one grid cell / grain — smaller is finer and slower, larger is chunkier and faster
releaseChance0.022Base probability a sampled cell erodes this frame — raise for a faster crumble
edgeReleaseMultiplier3.3How much more likely edge cells are to erode than interior cells — lower values hollow the word out more uniformly
gravity850Downward acceleration in px/s² applied to falling grains
airDrag0.992Per-frame velocity damping — closer to 1 lets grains drift further before settling
pileRelaxStepsPerFrame5How many relaxation passes run per frame — more passes settle the pile faster but cost more CPU
pileHoldSeconds0.8Pause after the pile finishes settling before the reform begins
reformDurationSeconds2Base flight time for a reforming grain, randomized ±15–25% per grain
reformStaggerSeconds0.65Maximum random launch delay between grains, creating the staggered takeoff
formedHoldSeconds3How long the completed word holds before the next erosion cycle starts

Full Source Code

The complete effect is a single self-contained HTML document — Canvas 2D only, no external scripts, no build step. The SandTextScene class and its helper functions follow directly below the markup. Open it in any modern browser; there is nothing to install.

HTML — sand-text-animation.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/sand-text-animation.html

One HTML file, one class, two bitmasks, five phases. The entire sand text animation — edge-weighted erosion, gravity and drift, pile settling that finds its own angle of repose, and an eased, wobbling flight back into formation — is roughly 400 lines of vanilla JavaScript. No framework. No canvas library. Just requestAnimationFrame and arithmetic.

Want to see it in action?Watch the full build on YouTube