Baktiyar Ablaikhan

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).