Cloud Mask Corners
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
Two small text blocks in opposite corners materialise through a soft cloudy mask, then clear and return with new words.
Code
"use client";
import { createNoise3D } from "simplex-noise";
import { clamp, makeCanvas, mulberry32, paintCloudMask, smooth, useStage, type Noise3, type Scene } from "./stage";
/*
* Two small text blocks in opposite corners materialise through a soft cloudy
* mask while un-blurring into focus, hold, clear the same way, and come back
* with new words.
*/
const SETS: [string[], string[]][] = [
[["Form follows", "feeling."], ["Slow is", "smooth."]],
[["Quiet type,", "loud ideas."], ["Made in", "the margins."]],
[["Fog lifts,", "words land."], ["Nothing", "is final."]],
[["Read between", "the clouds."], ["Soft edges,", "sharp intent."]],
];
const IN = 1.3;
const HOLD = 1.9;
const OUT = 1.0;
const GAP = 0.45;
const CYCLE = IN + HOLD + OUT + GAP;
const GW = 40;
const GH = 22;
type Block = {
text: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D };
tmp: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D };
mask: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D };
img: ImageData;
key: string;
};
function createScene(): Scene {
const noise: Noise3 = createNoise3D(mulberry32(21));
let blocks: Block[] = [];
let bw = 0;
let bh = 0;
let r = 1;
const setup = (w: number, h: number, dpr: number) => {
r = Math.min(2, dpr);
bw = Math.round(w * 0.42);
bh = Math.round(h * 0.3);
blocks = [0, 1].map(() => {
const mask = makeCanvas(GW, GH);
return {
text: makeCanvas(bw * r, bh * r),
tmp: makeCanvas(bw * r, bh * r),
mask,
img: mask.ctx.createImageData(GW, GH),
key: "",
};
});
};
const renderText = (b: Block, lines: string[], right: boolean, h: number) => {
const c = b.text.ctx;
c.setTransform(r, 0, 0, r, 0, 0);
c.clearRect(0, 0, bw, bh);
const size = Math.max(11, h * 0.075);
c.font = `600 ${size}px "Helvetica Neue", Inter, "Segoe UI", Arial, sans-serif`;
c.fillStyle = right ? "#2b46b8" : "#151515";
c.textAlign = right ? "right" : "left";
c.textBaseline = "top";
lines.forEach((line, i) => c.fillText(line, right ? bw - 2 : 2, (right ? bh - lines.length * size * 1.15 : 0) + i * size * 1.15));
};
return {
resize(w, h, dpr) {
setup(w, h, dpr);
},
frame({ ctx, w, h, t }) {
ctx.fillStyle = "#fbfaf7";
ctx.fillRect(0, 0, w, h);
if (!blocks.length) return;
const pad = Math.min(w, h) * 0.08;
blocks.forEach((b, bi) => {
const tt = t - bi * 0.35;
const n = Math.floor(tt / CYCLE);
const local = tt - n * CYCLE;
const set = SETS[((n % SETS.length) + SETS.length) % SETS.length];
const lines = set[bi];
const key = `${n}:${bi}`;
if (b.key !== key) {
renderText(b, lines, bi === 1, h);
b.key = key;
}
let prog: number;
if (local < IN) prog = smooth(local / IN);
else if (local < IN + HOLD) prog = 1;
else if (local < IN + HOLD + OUT) prog = 1 - smooth((local - IN - HOLD) / OUT);
else prog = 0;
if (prog <= 0.001) return;
paintCloudMask(b.mask.ctx, b.img, noise, prog, tt + bi * 10, 0.16, bi ? [0.4, 0.6] : [0.6, 0.4]);
const T = b.tmp.ctx;
const W = b.tmp.c.width;
const H = b.tmp.c.height;
T.globalCompositeOperation = "source-over";
T.clearRect(0, 0, W, H);
const blur = (1 - prog) * 7 * r;
if (blur > 0.3) {
T.save();
T.shadowColor = bi ? "#2b46b8" : "#151515";
T.shadowBlur = blur;
T.shadowOffsetX = W * 2;
T.drawImage(b.text.c, -W * 2, 0);
T.restore();
T.globalAlpha = clamp((prog - 0.5) * 2);
T.drawImage(b.text.c, 0, 0);
T.globalAlpha = 1;
} else T.drawImage(b.text.c, 0, 0);
T.globalCompositeOperation = "destination-in";
T.imageSmoothingEnabled = true;
T.drawImage(b.mask.c, 0, 0, W, H);
const x = bi ? w - pad - bw : pad;
const y = bi ? h - pad - bh : pad;
ctx.drawImage(b.tmp.c, x, y, bw, bh);
});
},
};
}
export default function CloudCorners() {
const ref = useStage(createScene, 2);
return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#fbfaf7]" />;
}
Prompt
Build "Cloud Mask Corners", a text animation. What it does: Two small text blocks in opposite corners materialise through a soft cloudy mask, then clear and return with new words. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using simplex-noise. 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).