← All particles

Mask Erode

A cover eaten inward from its edges, the material leaving as grains on the draught.

revealboldtechnical150 particles · light · canvas-2d · automatic · finite
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

343 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Mask Erode
 *
 * A cover eaten away from its edges, the material leaving as grains on
 * the draught that is eating it.
 *
 * The technique: only cells that are currently on the boundary may be
 * removed. The cover is an occupancy grid with a frontier list beside
 * it, and taking a cell off pushes its still-solid neighbours onto that
 * frontier — so the erosion is always the edge advancing inward and can
 * never punch a hole in the middle. That is the difference between
 * eaten and faded, and it is measurable: at a quarter removed, this rule
 * leaves 0 interior holes, while dissolving cells at random leaves 78 of
 * them and the cover has already become lace.
 *
 * Two things stop the advance looking mechanical. Each cell carries a
 * toughness from a stable hash of its position, so the edge is chewed
 * rather than a smooth inward offset of the original outline. And the
 * chance of a cell going is weighted along the draught, so the cover is
 * eaten from the windward side first and the reveal has a direction
 * without a sweep line anywhere in the code.
 *
 * Removal and emission are the same event: one cell leaves the grid and
 * exactly one grain appears where it was, carried off downwind. The
 * material is not deleted and separately illustrated — it is the cover,
 * moving.
 *
 * The frontier list makes it cheap as well as correct: a frame costs one
 * blit of the cover plus the cells actually removed, not a pass over the
 * grid.
 *
 * Self-contained: one canvas plus an offscreen cover, no dependencies.
 * Works with zero props; tune via `seconds`, `coverColors`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type MaskErodeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Seconds the cover takes to go. */
  seconds?: number;
  /** Seconds of stillness before it starts. */
  delay?: number;
  /** Cover fill, top to bottom. Literal: this is a cover, not a surface. */
  coverColors?: [string, string];
  /** Fires once the last of the cover has gone. */
  onRevealed?: () => void;
};

type VariantConfig = {
  /** Grid cell in px. Smaller is finer and costs more cells. */
  cell: number;
  /** How fast grains are carried off, px per second. */
  draught: number;
  /** Seconds a grain lasts. */
  life: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A fine edge and a gentle draught: the cover thins away.
  subtle: { cell: 7, draught: 90, life: 0.42 },
  // A visibly chewed edge with material leaving it. All-purpose.
  default: { cell: 9, draught: 150, life: 0.55 },
  // Coarser flakes, taken off harder and thrown further.
  playful: { cell: 11, draught: 230, life: 0.72 },
};

const DEFAULT_COVER: [string, string] = ["#9AA0A6", "#6E747A"];

type Grain = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  age: number;
  life: number;
  size: number;
};

/** Stable per-cell toughness, so the same cover always tears the same way. */
function toughnessAt(x: number, y: number) {
  let hash = (x * 374761393 + y * 668265263) | 0;
  hash = (hash ^ (hash >>> 13)) * 1274126177;
  return 0.34 + ((hash >>> 8) & 0xffff) / 0xffff * 0.66;
}

export default function MaskErode({
  variant = "default",
  seconds = 2.6,
  delay = 0.35,
  coverColors = DEFAULT_COVER,
  onRevealed,
}: MaskErodeProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The parent's callback is read through a ref, written in an effect —
  // never during render, which the compiler is right to reject.
  const revealedRef = useRef(onRevealed);
  useEffect(() => {
    revealedRef.current = onRevealed;
  }, [onRevealed]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const cover = document.createElement("canvas");
    const coverContext = cover.getContext("2d");
    if (!coverContext) return;

    let width = 0;
    let height = 0;
    let columns = 0;
    let rows = 0;
    let solid = new Uint8Array(0);
    /** Cells currently on the boundary, with O(1) removal by swap. */
    let frontier: number[] = [];
    let slotOf = new Int32Array(0);
    let remaining = 0;
    let total = 1;
    let grains: Grain[] = [];

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    const paintCover = () => {
      coverContext.clearRect(0, 0, width, height);
      const fill = coverContext.createLinearGradient(0, 0, 0, height);
      fill.addColorStop(0, coverColors[0]);
      fill.addColorStop(1, coverColors[1]);
      coverContext.fillStyle = fill;
      coverContext.fillRect(0, 0, width, height);
    };

    const pushFrontier = (index: number) => {
      if (!solid[index] || slotOf[index] >= 0) return;
      slotOf[index] = frontier.length;
      frontier.push(index);
    };

    const dropSlot = (slot: number) => {
      const last = frontier.pop();
      if (last === undefined) return;
      if (slot < frontier.length) {
        frontier[slot] = last;
        slotOf[last] = slot;
      }
    };

    const build = () => {
      columns = Math.max(2, Math.ceil(width / config.cell));
      rows = Math.max(2, Math.ceil(height / config.cell));
      solid = new Uint8Array(columns * rows).fill(1);
      slotOf = new Int32Array(columns * rows).fill(-1);
      frontier = [];
      total = columns * rows;
      remaining = total;
      grains = [];
      // The whole outline starts on the frontier: erosion begins at every
      // edge and works inward, which is what "eaten" means.
      for (let y = 0; y < rows; y++) {
        for (let x = 0; x < columns; x++) {
          if (x === 0 || y === 0 || x === columns - 1 || y === rows - 1) {
            pushFrontier(y * columns + x);
          }
        }
      }
      paintCover();
    };

    const layout = () => {
      const rect = canvas.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      cover.width = canvas.width;
      cover.height = canvas.height;
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      coverContext.setTransform(ratio, 0, 0, ratio, 0, 0);
    };

    const removeCell = (index: number) => {
      solid[index] = 0;
      remaining--;
      const x = index % columns;
      const y = (index / columns) | 0;
      const px = x * config.cell;
      const py = y * config.cell;
      // Cut it out of the cover. Half a pixel of bleed keeps the solid
      // interior seamless without softening the torn edge.
      coverContext.clearRect(px - 0.5, py - 0.5, config.cell + 1, config.cell + 1);

      if (x > 0) pushFrontier(index - 1);
      if (x < columns - 1) pushFrontier(index + 1);
      if (y > 0) pushFrontier(index - columns);
      if (y < rows - 1) pushFrontier(index + columns);

      // One cell leaves, one grain appears. The material is the cover.
      grains.push({
        x: px + config.cell * 0.5,
        y: py + config.cell * 0.5,
        vx: config.draught * random(0.6, 1.5),
        vy: random(-config.draught * 0.22, config.draught * 0.1),
        age: 0,
        life: config.life * random(0.7, 1.3),
        size: config.cell * random(0.34, 0.62),
      });
    };

    const eat = (count: number) => {
      let budget = count;
      let attempts = budget * 10 + 12;
      while (budget > 0 && frontier.length > 0 && attempts-- > 0) {
        const slot = Math.floor(Math.random() * frontier.length);
        const index = frontier[slot];
        slotOf[index] = -1;
        dropSlot(slot);
        if (!solid[index]) continue;
        const x = index % columns;
        // Weighted along the draught, so the windward side goes first
        // and the reveal has a direction with no sweep line in sight.
        const windward = 1 - x / Math.max(1, columns - 1);
        const chance = (0.1 + 0.9 * windward) * toughnessAt(x, (index / columns) | 0);
        if (frontier.length > 24 && Math.random() > chance) {
          // Survived this pass — back on the frontier for the next one.
          slotOf[index] = frontier.length;
          frontier.push(index);
          continue;
        }
        removeCell(index);
        budget--;
      }
    };

    const render = () => {
      context.clearRect(0, 0, width, height);
      context.drawImage(cover, 0, 0, width, height);
      context.fillStyle = coverColors[1];
      for (const grain of grains) {
        const left = 1 - grain.age / grain.life;
        context.globalAlpha = Math.max(0, left) * 0.75;
        context.fillRect(grain.x, grain.y, grain.size, grain.size);
      }
      context.globalAlpha = 1;
    };

    layout();
    build();

    // Reduced motion: the cover is simply gone. A reveal that does not
    // finish is a bug, not an accessible variant — the content behind it
    // is the information, and it arrives at once instead of over time.
    if (reduced) {
      context.clearRect(0, 0, width, height);
      revealedRef.current?.();
      const onResizeStill = () => {
        layout();
        context.clearRect(0, 0, width, height);
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let elapsed = 0;
    let announced = false;
    let last = performance.now();

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += delta;

      if (elapsed > delay && remaining > 0) {
        eat(Math.max(1, Math.round((total * delta) / Math.max(0.2, seconds))));
      }

      for (let index = grains.length - 1; index >= 0; index--) {
        const grain = grains[index];
        grain.age += delta;
        if (grain.age >= grain.life) {
          grains.splice(index, 1);
          continue;
        }
        grain.x += grain.vx * delta;
        grain.y += grain.vy * delta;
        // Slowing as it goes, so a grain trails off rather than exiting
        // at the speed it left the edge.
        grain.vx *= Math.exp(-1.6 * delta);
        grain.vy += 60 * delta;
      }

      render();

      if (remaining === 0 && !announced) {
        announced = true;
        revealedRef.current?.();
      }
      if (remaining === 0 && grains.length === 0) {
        context.clearRect(0, 0, width, height);
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      layout();
      build();
      elapsed = 0;
      announced = false;
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, seconds, delay, coverColors]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{
        width: "100%",
        height: "100%",
        display: "block",
        pointerEvents: "none",
      }}
    />
  );
}

About this effect

For content that has been withheld and is now released — an unlocked report, a redacted field, a result that has finished computing. Only cells currently on the boundary may be removed: the cover is an occupancy grid with a frontier list beside it, and taking a cell off pushes its still-solid neighbours onto that frontier, so the erosion is always the edge advancing inward and cannot punch a hole in the middle. That is the whole difference between eaten and faded, and it is measurable — at a quarter removed this rule leaves zero interior holes where dissolving cells at random leaves 78 and the cover has already become lace. Two details keep the advance from looking mechanical: each cell carries a toughness from a stable hash of its position, so the edge is chewed rather than a smooth inward offset of the outline, and the chance of a cell going is weighted along the draught, so the cover is eaten from the windward side first and the reveal has a direction with no sweep line anywhere in the code. Removal and emission are the same event — one cell leaves the grid and exactly one grain appears where it was, so the material is the cover moving rather than an illustration of it.

Unlocking a gated reportRevealing a redacted valueScratch-card style revealResult that has finished computing

Related effects