Cloud letters
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
White letters drift in as soft clouds on a pale sky, come into grainy focus, then lift away; the pointer breathes on the nearest letters.
Code
"use client";
import {
CanvasStudy,
easeIn,
easeOut,
hash,
makeCanvas,
prog,
supportsFilter,
type Frame,
type Painter,
} from "./stage";
export const BG = "#a9d4f2";
const TEXT = "Drift into focus";
const CYCLE = 9.5;
type Letter = {
ch: string;
x: number; // centre x
pts: [number, number][]; // interior samples relative to the centre
edge: [number, number][]; // edge samples
step: number;
breath: number; // smoothed pointer influence
};
function puffSprite() {
const { c, ctx } = makeCanvas(64, 64);
const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
g.addColorStop(0, "rgba(255,255,255,0.9)");
g.addColorStop(0.45, "rgba(255,255,255,0.35)");
g.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, 64);
return c;
}
export function create(): Painter {
let key = "";
let letters: Letter[] = [];
let fs = 0;
let puff: HTMLCanvasElement | null = null;
const build = (ctx: CanvasRenderingContext2D, w: number, h: number, font: string) => {
ctx.font = `700 100px ${font}`;
fs = Math.min(h * 0.24, (w * 0.82 * 100) / ctx.measureText(TEXT).width);
const f = `700 ${fs}px ${font}`;
ctx.font = f;
const total = ctx.measureText(TEXT).width;
let x = (w - total) / 2;
letters = [];
const step = Math.max(2, Math.round(fs / 26));
for (const ch of TEXT) {
const cw = ctx.measureText(ch).width;
if (ch !== " ") {
const pad = fs * 0.2;
const W = Math.ceil(cw + pad * 2);
const H = Math.ceil(fs * 1.4);
const { ctx: o } = makeCanvas(W, H);
o.font = f;
o.textBaseline = "middle";
o.fillStyle = "#fff";
o.fillText(ch, pad, H / 2);
const data = o.getImageData(0, 0, W, H).data;
const on = (px: number, py: number) => px >= 0 && py >= 0 && px < W && py < H && data[(py * W + px) * 4 + 3] > 120;
const pts: [number, number][] = [];
const edge: [number, number][] = [];
for (let py = 0; py < H; py += step) {
for (let px = 0; px < W; px += step) {
if (!on(px, py)) continue;
const p: [number, number] = [px - W / 2, py - H / 2];
pts.push(p);
if (!on(px + step, py) || !on(px - step, py) || !on(px, py + step) || !on(px, py - step)) edge.push(p);
}
}
letters.push({ ch, x: x + cw / 2, pts, edge, step, breath: 0 });
}
x += cw;
}
};
return ({ ctx, w, h, t, dt, p, fonts }: Frame) => {
const k = `${w}|${h}|${fonts.sans}|${typeof document !== "undefined" ? document.fonts?.status : ""}`;
if (k !== key) {
key = k;
build(ctx, w, h, fonts.sans);
}
puff ??= puffSprite();
ctx.fillStyle = BG;
ctx.fillRect(0, 0, w, h);
const u = t % CYCLE;
const baseY = h * 0.5 - (Math.max(0, u - 1.5) / CYCLE) * h * 0.14;
const filter = supportsFilter(ctx);
ctx.font = `700 ${fs}px ${fonts.sans}`;
ctx.textBaseline = "middle";
const n = letters.length;
letters.forEach((L, i) => {
const arrive = easeOut(prog(u, 0.3 + i * 0.12, 1.5));
const leave = easeIn(prog(u, 5.6 + (n - 1 - i) * 0.07 + i * 0.03, 2.0));
if (arrive <= 0) return;
// pointer: a breath of wind on the nearest letters
const dx0 = p.x - L.x;
const dy0 = p.y - baseY;
const near = p.inside ? Math.exp(-(dx0 * dx0 + dy0 * dy0) / (fs * fs * 0.9)) : 0;
L.breath += (near - L.breath) * Math.min(1, dt * 6);
const soft = Math.min(1, (1 - arrive) ** 0.7 + leave + L.breath * 0.18);
const ox = (1 - arrive) * -w * 0.32 + leave * w * 0.3;
const oy = (1 - arrive) * h * 0.38 - leave * h * 0.42 - L.breath * fs * 0.07;
const alpha = 1 - leave ** 1.4;
const cx = L.x + ox;
const cy = baseY + oy;
if (alpha <= 0.01) return;
// crisp body
const body = alpha * (1 - soft) ** 1.6;
if (body > 0.01) {
ctx.globalAlpha = body;
if (filter && soft > 0.02) ctx.filter = `blur(${(soft * fs * 0.12).toFixed(1)}px)`;
ctx.fillStyle = "#ffffff";
ctx.fillText(L.ch, cx - ctx.measureText(L.ch).width / 2, cy);
if (filter) ctx.filter = "none";
}
// cloud puffs
if (soft > 0.03 && puff) {
const r = L.step * (1.6 + soft * 5);
const density = 1 - leave * 0.85;
ctx.globalAlpha = Math.min(1, alpha * soft * 0.5);
L.pts.forEach(([px, py], j) => {
if (hash(j, i) > density) return;
const drift = soft * fs * 0.35;
const wob = t * 0.6 + j;
const qx = cx + px * (1 + soft * 0.3) + (hash(j, 3) - 0.5) * drift + Math.sin(wob) * soft * 3;
const qy = cy + py * (1 + soft * 0.3) + (hash(j, 9) - 0.5) * drift + Math.cos(wob * 0.8) * soft * 3;
ctx.drawImage(puff as HTMLCanvasElement, qx - r, qy - r, r * 2, r * 2);
});
}
// fine grain along the edges
if (soft < 0.7) {
ctx.globalAlpha = alpha * (1 - soft) * 0.75;
ctx.fillStyle = "#ffffff";
const g = Math.floor(t * 12);
L.edge.forEach(([px, py], j) => {
for (let s = 0; s < 2; s++) {
const jx = (hash(j * 3 + s, g) - 0.5) * L.step * 2.4;
const jy = (hash(j * 5 + s, g + 1) - 0.5) * L.step * 2.4;
ctx.fillRect(cx + px + jx, cy + py + jy, 1.2, 1.2);
}
});
}
});
ctx.globalAlpha = 1;
};
}
export default function CloudLetters() {
return (
<CanvasStudy
create={create}
background={BG}
still={3}
label="White letters arrive as soft clouds on a pale sky, come into focus, drift up and lift away; the pointer breathes on the nearest letters."
/>
);
}
Prompt
Build "Cloud letters", a text animation. What it does: White letters drift in as soft clouds on a pale sky, come into grainy focus, then lift away; the pointer breathes on the nearest letters. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://www.arlan.me/vault (Arlan, MIT).