Baktiyar Ablaikhan

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