Verb sentences
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
Six short 'Design …' sentences cut every half second, each verb acting on its own letters — echoing, bending, sorting.
Code
"use client";
import { CanvasStudy, easeOut, easeOutBack, hash, lerp, prog, type Frame, type Painter } from "./stage";
export const BG = "#0d191c";
const INK = "#e4efe2";
const STEP = 0.5; // hard cut every half second
type Glyph = { ch: string; x: number; w: number };
const SENTENCES: [string, string][] = [
["demands", "courage"],
["echoes", "intent"],
["carries", "weight"],
["turns", "heads"],
["bends", "habits"],
["sorts", "chaos"],
];
function layout(ctx: CanvasRenderingContext2D, text: string, x0: number): Glyph[] {
const out: Glyph[] = [];
let x = x0;
for (const ch of text) {
const w = ctx.measureText(ch).width;
out.push({ ch, x, w });
x += w;
}
return out;
}
function glyph(ctx: CanvasRenderingContext2D, g: Glyph, y: number, opts: { dx?: number; dy?: number; rot?: number; sx?: number; sy?: number } = {}) {
const { dx = 0, dy = 0, rot = 0, sx = 1, sy = 1 } = opts;
ctx.save();
ctx.translate(g.x + g.w / 2 + dx, y + dy);
ctx.rotate(rot);
ctx.scale(sx, sy);
ctx.fillText(g.ch, -g.w / 2, 0);
ctx.restore();
}
export function create(): Painter {
return ({ ctx, w, h, t, fonts }: Frame) => {
ctx.fillStyle = BG;
ctx.fillRect(0, 0, w, h);
const idx = Math.floor(t / STEP) % SENTENCES.length;
const u = (t % STEP) / STEP;
const [verb, noun] = SENTENCES[idx];
const line2 = `${verb} ${noun}`;
ctx.font = `500 100px ${fonts.sans}`;
const fs = Math.min(h * 0.19, (w * 0.7 * 100) / ctx.measureText("demands courage").width);
ctx.font = `500 ${fs}px ${fonts.sans}`;
ctx.textBaseline = "alphabetic";
ctx.fillStyle = INK;
const w2 = ctx.measureText(line2).width;
const x2 = (w - w2) / 2;
const y1 = h / 2 - fs * 0.12;
const y2 = h / 2 + fs * 0.88;
const dW = ctx.measureText("Design").width;
ctx.fillText("Design", (w - dW) / 2, y1);
const verbG = layout(ctx, verb, x2);
const nounG = layout(ctx, noun, x2 + ctx.measureText(`${verb} `).width);
const k = easeOut(Math.min(1, u / 0.75));
switch (verb) {
case "demands": {
verbG.forEach((g) => glyph(ctx, g, y2));
// each letter of courage asks for more room than the last
let x = nounG[0].x;
nounG.forEach((g, i) => {
const s = 1 + 0.75 * k * (i / (nounG.length - 1)) ** 1.4;
glyph(ctx, { ...g, x, w: g.w * s }, y2, { sx: s, sy: s });
x += g.w * s;
});
break;
}
case "echoes": {
verbG.forEach((g) => glyph(ctx, g, y2));
for (let c = 4; c >= 0; c--) {
const e = Math.max(0, k * 5 - c);
if (c > 0 && e <= 0) continue;
const kk = Math.min(1, e);
const dx = -c * fs * 0.1 * kk;
const dy = -c * fs * 0.42 * kk;
ctx.globalAlpha = c === 0 ? 1 : 0.55 + 0.45 * (1 - c / 5);
nounG.forEach((g) => glyph(ctx, g, y2, { dx, dy }));
}
ctx.globalAlpha = 1;
break;
}
case "carries": {
const lift = easeOut(Math.min(1, u / 0.5));
verbG.forEach((g, i) => glyph(ctx, g, y2, { dy: -fs * 0.08 * lift * Math.sin(((i + 0.5) / verbG.length) * Math.PI) }));
const sag = easeOutBack(Math.min(1, u / 0.6));
nounG.forEach((g, i) => {
const m = Math.sin(((i + 0.5) / nounG.length) * Math.PI);
glyph(ctx, g, y2, { dy: fs * 0.32 * sag * m, sy: 1 - 0.12 * sag * m, sx: 1 + 0.1 * sag * m });
});
break;
}
case "turns": {
verbG.forEach((g) => glyph(ctx, g, y2));
nounG.forEach((g, i) => {
const kk = easeOut(prog(u, i * 0.08, 0.5));
glyph(ctx, g, y2, { sx: Math.cos(kk * Math.PI * 2), rot: Math.sin(kk * Math.PI) * 0.18 });
});
break;
}
case "bends": {
const all = [...verbG, ...nounG];
const cx = w / 2;
const bend = (k * 0.9) / (w2 * 0.5);
all.forEach((g) => {
const d = g.x + g.w / 2 - cx;
glyph(ctx, g, y2, { dy: bend * d * d * 0.4 - fs * 0.1 * k, rot: bend * d * 0.8 });
});
break;
}
case "sorts": {
verbG.forEach((g) => glyph(ctx, g, y2));
const sorted = [...nounG].map((g, i) => ({ g, i })).sort((a, b) => a.g.ch.localeCompare(b.g.ch));
sorted.forEach(({ g, i }, slot) => {
const target = nounG[slot].x;
const kk = easeOut(prog(u, 0.05 + i * 0.05, 0.55));
const jx = (hash(i, idx) - 0.5) * fs * 0.9 * (1 - kk);
const jy = (hash(i + 7, idx) - 0.5) * fs * 1.2 * (1 - kk);
const rot = (hash(i + 3) - 0.5) * 1.6 * (1 - kk);
glyph(ctx, { ...g, x: lerp(g.x, target, kk) }, y2, { dx: jx, dy: jy, rot });
});
break;
}
}
};
}
export default function VerbSentences() {
return (
<CanvasStudy
create={create}
background={BG}
still={0.4}
label="Six two-line sentences that cut every half second; each verb acts on its own letters."
/>
);
}
Prompt
Build "Verb sentences", a text animation. What it does: Six short 'Design …' sentences cut every half second, each verb acting on its own letters — echoing, bending, sorting. 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 dark 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).