Loud sentence
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
A sentence types in word by word; 'loud' turns vivid, trembles and bursts into coloured strokes while the rest rushes off and strokes sweep the paper.
Code
"use client";
import { CanvasStudy, clamp, easeIn, easeInOut, hash, prog, supportsFilter, type Frame, type Painter } from "./stage";
export const BG = "#fbfaf7";
const INK = "#161616";
const SCENES: { text: string; palette: string[] }[] = [
{ text: "Some ideas are too loud to whisper", palette: ["#ff3b30", "#ffcc00", "#0a84ff", "#30d158"] },
{ text: "Make the quiet parts loud", palette: ["#ff2d95", "#5e5ce6", "#00c7be", "#ff9f0a"] },
{ text: "Good type can be loud and kind", palette: ["#e63946", "#2a9d8f", "#f4a261", "#3a0ca3"] },
];
const PER_WORD = 0.22;
type Placed = { word: string; x: number; y: number; w: number };
export function create(): Painter {
return ({ ctx, w, h, t, fonts }: Frame) => {
ctx.fillStyle = BG;
ctx.fillRect(0, 0, w, h);
// scene timings
const sceneLen = (s: (typeof SCENES)[number]) => 0.2 + s.text.split(" ").length * PER_WORD + 3.6;
const loop = SCENES.reduce((a, s) => a + sceneLen(s), 0);
let u = t % loop;
let idx = 0;
while (u > sceneLen(SCENES[idx])) {
u -= sceneLen(SCENES[idx]);
idx = (idx + 1) % SCENES.length;
}
const { text, palette } = SCENES[idx];
const words = text.split(" ");
const typedEnd = 0.2 + words.length * PER_WORD + 0.3;
const colourAt = typedEnd;
const burstAt = typedEnd + 1.0;
const sweepAt = burstAt + 0.9;
// layout, wrapped
const fs = Math.min(h * 0.16, w * 0.085);
ctx.font = `400 ${fs}px ${fonts.serif}`;
ctx.textBaseline = "middle";
const space = ctx.measureText(" ").width;
const lines: Placed[][] = [[]];
let lw = 0;
for (const word of words) {
const ww = ctx.measureText(word).width;
if (lw > 0 && lw + space + ww > w * 0.8) {
lines.push([]);
lw = 0;
}
const line = lines[lines.length - 1];
line.push({ word, x: lw + (lw > 0 ? space : 0), y: 0, w: ww });
lw += (lw > 0 ? space : 0) + ww;
}
const lead = fs * 1.15;
const placed: Placed[] = [];
lines.forEach((line, li) => {
const width = line.reduce((a, p) => Math.max(a, p.x + p.w), 0);
const y = h / 2 + (li - (lines.length - 1) / 2) * lead;
line.forEach((p) => placed.push({ ...p, x: p.x + (w - width) / 2, y }));
});
const loudIdx = words.indexOf("loud");
const loud = placed[loudIdx];
const loudCx = loud.x + loud.w / 2;
const filter = supportsFilter(ctx);
const rush = easeIn(prog(u, burstAt + 0.05, 0.6));
placed.forEach((p, i) => {
const landed = u - (0.2 + i * PER_WORD);
if (landed < 0) return;
const focus = clamp(landed / 0.2);
ctx.save();
if (i === loudIdx) {
if (u >= burstAt) {
ctx.restore();
return;
}
const c = clamp((u - colourAt) / 0.3);
const tremble = clamp((u - colourAt - 0.2) / 0.8) * fs * 0.05;
ctx.translate((hash(Math.floor(t * 40), 1) - 0.5) * tremble * 2, (hash(Math.floor(t * 40), 2) - 0.5) * tremble * 2);
ctx.fillStyle = c > 0.5 ? palette[0] : INK;
} else {
const dir = p.x + p.w / 2 < loudCx ? -1 : 1;
ctx.translate(dir * rush * w * 1.1, 0);
ctx.fillStyle = INK;
}
if (focus < 1) {
if (filter) ctx.filter = `blur(${((1 - focus) * fs * 0.08).toFixed(1)}px)`;
else ctx.globalAlpha = 0.4 + focus * 0.6;
const s = 1 + (1 - focus) * 0.06;
ctx.translate(p.x + p.w / 2, p.y);
ctx.scale(s, s);
ctx.translate(-(p.x + p.w / 2), -p.y);
}
ctx.fillText(p.word, p.x, p.y);
ctx.restore();
});
// loud dissolves into drawn strokes scattering in every direction
const age = u - burstAt;
if (age > 0 && age < 1.6) {
ctx.lineCap = "round";
for (let i = 0; i < 46; i++) {
const ox = loud.x + hash(i, 1) * loud.w;
const oy = loud.y + (hash(i, 2) - 0.5) * fs * 0.6;
const ang = hash(i, 3) * Math.PI * 2;
const speed = (0.25 + hash(i, 4) * 0.7) * Math.max(w, h);
const curl = (hash(i, 5) - 0.5) * 3;
const pos = (a: number) => {
const d = speed * (1 - Math.exp(-a * 2.2)) * 0.6;
const th = ang + curl * a;
return [ox + Math.cos(th) * d, oy + Math.sin(th) * d];
};
ctx.strokeStyle = palette[i % palette.length];
ctx.lineWidth = 1.5 + hash(i, 6) * fs * 0.08;
ctx.globalAlpha = clamp(1.4 - age);
ctx.beginPath();
for (let s = 0; s <= 6; s++) {
const [x, y] = pos(Math.max(0, age - 0.25 + (s / 6) * 0.25));
if (s === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// a few last strokes sweep the paper clean
for (let k = 0; k < 3; k++) {
const start = sweepAt + k * 0.18;
const head = easeInOut(prog(u, start, 0.6));
const tail = easeInOut(prog(u, start + 0.3, 0.6));
if (head <= 0 || tail >= 1) continue;
const y = h * (0.3 + k * 0.2) + Math.sin(k * 2.1) * h * 0.04;
ctx.strokeStyle = palette[(k + 1) % palette.length];
ctx.lineWidth = h * 0.16;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(-w * 0.2 + tail * w * 1.4, y + Math.sin(tail * 3 + k) * h * 0.03);
ctx.lineTo(-w * 0.2 + head * w * 1.4, y + Math.sin(head * 3 + k) * h * 0.03);
ctx.stroke();
}
};
}
export default function LoudSentence() {
return (
<CanvasStudy
create={create}
background={BG}
still={2.2}
label="A sentence types itself word by word; 'loud' turns vivid, trembles and bursts into coloured strokes while the rest rushes off, then strokes sweep the paper clean."
/>
);
}
Prompt
Build "Loud sentence", a text animation. What it does: A sentence types in word by word; 'loud' turns vivid, trembles and bursts into coloured strokes while the rest rushes off and strokes sweep the paper. 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).