Mask Erode
A cover eaten inward from its edges, the material leaving as grains on the draught.
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.
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.