Text Scatter In
Words arriving as clouds of particles that tighten into their own glyphs.
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 · Text Scatter In
*
* Letters arriving as clouds of particles that resolve into glyphs.
* The technique is where the glyph targets come from: the text is drawn
* once into an offscreen canvas and its alpha channel is read back on a
* grid, keeping the cells the letters actually cover. That is what lets
* it take any string in whatever font is really rendering on the page —
* no per-letter path data, no font file to parse, and the particle
* shapes match the type the reader is already looking at.
*
* Because every target knows its own x, the stagger is free: a
* particle's delay is its horizontal position, so the word resolves
* left to right the way it would be read.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; replay by incrementing `runKey`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TextScatterInProps = {
/** Visual character of the arrival. */
variant?: "subtle" | "default" | "playful";
/** The words to resolve. */
text?: string;
/** Increment to run it again. It also runs once on mount. */
runKey?: number;
/** Type size in px. */
fontSize?: number;
fontWeight?: number;
/** Font stack. Whatever is passed here is what gets sampled. */
fontFamily?: string;
/** Particle color. Defaults to the inherited text color. */
color?: string;
/** Ceiling on particles, whatever the string. */
maxParticles?: number;
/** Fires once the text has fully resolved. */
onSettled?: () => void;
};
type VariantConfig = {
/** Seconds one particle spends travelling. */
travel: number;
/** Seconds between the first letter starting and the last. */
stagger: number;
/** How far out a particle starts, in px. */
scatter: number;
/** Sampling step in px — smaller means denser letters. */
step: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short hop from just beside the glyph. Reads as a settle.
subtle: { travel: 0.5, stagger: 0.24, scatter: 16, step: 4 },
// Clearly a cloud gathering into words. All-purpose.
default: { travel: 0.7, stagger: 0.4, scatter: 34, step: 3.5 },
// A wider scatter that takes its time coming in.
playful: { travel: 0.9, stagger: 0.6, scatter: 60, step: 3 },
};
type Particle = {
fromX: number;
fromY: number;
toX: number;
toY: number;
delay: number;
progress: number;
};
const SYSTEM_STACK =
'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
export default function TextScatterIn({
variant = "default",
text = "Welcome back",
runKey = 0,
fontSize = 40,
fontWeight = 650,
fontFamily = SYSTEM_STACK,
color,
maxParticles = 320,
onSettled,
}: TextScatterInProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The callback is held in a ref and updated in its own effect, so an
// inline arrow from the parent cannot restart the arrival.
const settledRef = useRef(onSettled);
useEffect(() => {
settledRef.current = onSettled;
}, [onSettled]);
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;
let frame = 0;
let cancelled = false;
const start = () => {
if (cancelled) return;
const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
// Measure first, on a scratch context, so the visible canvas can
// be sized to the string instead of to a guess.
const scratch = document.createElement("canvas");
const scratchContext = scratch.getContext("2d", { willReadFrequently: true });
if (!scratchContext) return;
scratchContext.font = font;
const metrics = scratchContext.measureText(text);
const width = Math.ceil(metrics.width) + fontSize;
const height = Math.ceil(fontSize * 1.7);
// Sized to the string rather than to the container, so a window
// resize cannot change the layout — there is nothing to remeasure.
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
// The glyph mask, rasterized at 1× — this is only ever read as
// coverage, so device pixels would just be four times the work.
scratch.width = width;
scratch.height = height;
scratchContext.font = font;
scratchContext.textBaseline = "middle";
scratchContext.fillStyle = "#000000";
scratchContext.fillText(text, fontSize / 2, height / 2);
const pixels = scratchContext.getImageData(0, 0, width, height).data;
/** Grid cells the letters cover, at a given sampling step. */
const sampleAt = (step: number) => {
const found: [number, number][] = [];
for (let y = step / 2; y < height; y += step) {
for (let x = step / 2; x < width; x += step) {
const index = (Math.floor(y) * width + Math.floor(x)) * 4 + 3;
if (pixels[index] > 140) found.push([x, y]);
}
}
return found;
};
// Coarsen the grid until the count fits the ceiling, rather than
// sampling fine and throwing points away: dropping points at
// random leaves holes in the strokes, and a bigger step does not.
let step = config.step;
let spots = sampleAt(step);
while (spots.length > maxParticles && step < 12) {
step += 0.75;
spots = sampleAt(step);
}
const random = (min: number, max: number) => min + Math.random() * (max - min);
const ink = color ?? getComputedStyle(canvas).color ?? "#000000";
const dot = Math.max(1.1, step * 0.42);
const particles: Particle[] = spots.map(([x, y]) => {
const angle = random(0, Math.PI * 2);
const distance = config.scatter * random(0.4, 1);
return {
fromX: x + Math.cos(angle) * distance,
fromY: y + Math.sin(angle) * distance * 0.7,
toX: x,
toY: y,
// Reading order, for free: the delay is the target's own x.
delay: (x / width) * config.stagger + random(0, 0.05),
progress: 0,
};
});
const render = () => {
context.clearRect(0, 0, width, height);
context.fillStyle = ink;
for (const particle of particles) {
const eased = 1 - Math.pow(1 - particle.progress, 3);
const x = particle.fromX + (particle.toX - particle.fromX) * eased;
const y = particle.fromY + (particle.toY - particle.fromY) * eased;
context.globalAlpha = 0.15 + eased * 0.85;
// Slightly larger in flight, tightening as it lands — the
// letters sharpen instead of simply appearing.
context.beginPath();
context.arc(x, y, dot * (1.35 - eased * 0.35), 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
// Reduced motion: the resolved words, drawn where they land. The
// text is the content — it must never be withheld for an effect.
if (reduced) {
for (const particle of particles) particle.progress = 1;
render();
settledRef.current?.();
return;
}
let last = performance.now();
let elapsed = 0;
let announced = false;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
let settled = true;
for (const particle of particles) {
const age = elapsed - particle.delay;
if (age <= 0) {
settled = false;
continue;
}
particle.progress = Math.min(1, age / config.travel);
if (particle.progress < 1) settled = false;
}
render();
if (settled) {
// Resolved. The frame is static from here, so the loop ends
// rather than redrawing the same words sixty times a second.
if (!announced) {
announced = true;
settledRef.current?.();
}
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
};
// Measure the font that will actually render. Starting before a
// webfont has loaded samples the fallback, and the particles then
// spell the words in the wrong shapes.
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(start);
} else {
start();
}
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [variant, text, runKey, fontSize, fontWeight, fontFamily, color, maxParticles]);
return (
<canvas
ref={canvasRef}
role="img"
aria-label={text}
style={{ display: "block", maxWidth: "100%" }}
/>
);
}About this effect
A heading that gathers itself, for the one place it is affordable — a greeting above a dashboard that is already loaded. The targets come from rasterizing the string once into an offscreen canvas and reading its alpha channel on a grid, keeping the cells the letters cover. That is what lets it take any string in whatever font is really rendering on the page: no per-letter path data, no font file to parse, and the particles land in the shape of the type the reader is already looking at. Each target knows its own x, so the stagger is free — the delay is the horizontal position and the word resolves left to right. Density is controlled by coarsening the sampling grid rather than discarding points, which would leave holes in the strokes.