Baktiyar Ablaikhan

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