Words in action
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
MOVE, SKEW, ROTATE, BREAK and CONDENSE each act out their own meaning in their own colours; pressing holds the frame.
Code
"use client";
import { CanvasStudy, easeIn, easeInOut, easeOut, easeOutBack, hash, lerp, prog, type Frame, type Painter } from "./stage";
export const BG = "#2347e8";
const DUR = 2.5;
type Scene = { word: string; ink: string; ground: string };
const SCENES: Scene[] = [
{ word: "MOVE", ink: "#efe6d0", ground: "#2347e8" },
{ word: "SKEW", ink: "#ff4a1c", ground: "#f3eee2" },
{ word: "ROTATE", ink: "#f4e6d4", ground: "#134d37" },
{ word: "BREAK", ink: "#151515", ground: "#f6d80c" },
{ word: "CONDENSE", ink: "#ff7d63", ground: "#2e0c3c" },
];
/** Bounce settle for a drop of 1 → 0 progress (returns height fraction above floor). */
function bounce(k: number) {
if (k < 0.45) return 1 - (k / 0.45) ** 2;
if (k < 0.75) {
const s = (k - 0.6) / 0.15;
return 0.18 * (1 - s * s);
}
if (k < 0.92) {
const s = (k - 0.835) / 0.085;
return 0.05 * (1 - s * s);
}
return 0;
}
export function create(): Painter {
let clock = 0;
return ({ ctx, w, h, t, dt, p, fonts, reduced }: Frame) => {
if (reduced) clock = t;
else if (!p.down) clock += dt; // pressing holds the frame
const idx = Math.floor(clock / DUR) % SCENES.length;
const u = clock % DUR;
const { word, ink, ground } = SCENES[idx];
ctx.fillStyle = ground;
ctx.fillRect(0, 0, w, h);
ctx.font = `500 100px ${fonts.sans}`;
const fs = Math.min(h * 0.3, (w * 0.74 * 100) / ctx.measureText(word).width);
ctx.font = `500 ${fs}px ${fonts.sans}`;
ctx.textBaseline = "alphabetic";
ctx.fillStyle = ink;
const total = ctx.measureText(word).width;
const base = h / 2 + fs * 0.36;
const letters = [...word];
const xs: number[] = [];
const ws: number[] = [];
let x = (w - total) / 2;
for (const c of letters) {
const cw = ctx.measureText(c).width;
xs.push(x + cw / 2);
ws.push(cw);
x += cw;
}
const put = (i: number, cx: number, by: number, f?: () => void) => {
ctx.save();
ctx.translate(cx, by);
f?.();
ctx.fillText(letters[i], -ws[i] / 2, 0);
ctx.restore();
};
switch (word) {
case "MOVE": {
letters.forEach((_, i) => {
const hops = 3;
const pts: [number, number][] = [];
for (let k = 0; k < hops; k++) {
pts.push([w * (0.15 + hash(i * 7 + k, idx) * 0.7), h * (0.3 + hash(i * 11 + k, idx + 3) * 0.55)]);
}
pts.push([xs[i], base]);
const span = 1.7;
const g = Math.min(0.999, Math.max(0, (u - i * 0.06) / span)) * (pts.length - 1);
const seg = Math.floor(g);
const f = easeInOut(g - seg);
const [ax, ay] = pts[seg];
const [bx, by] = pts[Math.min(seg + 1, pts.length - 1)];
const arc = Math.sin(f * Math.PI) * fs * 0.45;
const landed = u - i * 0.06 >= span;
put(i, landed ? xs[i] : lerp(ax, bx, f), landed ? base : lerp(ay, by, f) - arc);
});
break;
}
case "SKEW": {
letters.forEach((_, i) => {
const k = easeOutBack(prog(u, 0.25 + i * 0.22, 0.45));
put(i, xs[i], base, () => ctx.transform(1, 0, -0.55 * k, 1, 0, 0));
});
break;
}
case "ROTATE": {
const k = easeInOut(prog(u, 0.25, 1.8));
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.rotate(k * Math.PI * 2);
// shrink a little while upright so the wheel stays inside the frame
const fit = Math.min(1, (h * 0.92) / total);
const s = 1 - (1 - fit) * Math.abs(Math.sin(k * Math.PI * 2));
ctx.scale(s, s);
ctx.translate(-w / 2, -h / 2);
letters.forEach((_, i) => {
let cx = xs[i];
let by = base;
if (i === 1 || i === 4) {
const other = i === 1 ? xs[4] : xs[1];
cx = lerp(xs[i], other, k);
by = base + Math.sin(k * Math.PI) * fs * 0.55 * (i === 1 ? -1 : 1);
}
put(i, cx, by);
});
ctx.restore();
break;
}
case "BREAK": {
const floor = h - fs * 0.08;
letters.forEach((_, i) => {
if (i < 2) return put(i, xs[i], base);
const k = prog(u, 0.35 + (i - 2) * 0.16, 1.1);
const height = bounce(k);
const by = lerp(floor, base, height);
const tip = (hash(i, 2) - 0.5) * 0.5 * Math.min(1, k * 2);
put(i, xs[i] + (hash(i, 5) - 0.5) * fs * 0.4 * k, by, () => ctx.rotate(tip));
});
break;
}
case "CONDENSE": {
const k = easeIn(prog(u, 0.3, 1.8));
const s = lerp(1, 0.006, easeOut(k));
ctx.save();
ctx.translate(w / 2, 0);
ctx.scale(s, 1);
ctx.translate(-w / 2, 0);
letters.forEach((_, i) => put(i, xs[i], base));
ctx.restore();
if (s < 0.05) {
ctx.fillRect(w / 2 - 0.5, base - fs * 0.72, 1, fs * 0.72);
}
break;
}
}
};
}
export default function WordActions() {
return (
<CanvasStudy
create={create}
background={BG}
still={1.6}
cursor="pointer"
label="MOVE, SKEW, ROTATE, BREAK and CONDENSE each act out their meaning in their own colours; pressing holds the frame."
/>
);
}
Prompt
Build "Words in action", a text animation. What it does: MOVE, SKEW, ROTATE, BREAK and CONDENSE each act out their own meaning in their own colours; pressing holds the frame. 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).