Baktiyar Ablaikhan

Flipping letters

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

A butter-yellow word on indigo becomes the next word as each letter flips through strangers and jumps to a new row; hovering a letter makes it flip.

Code

"use client";

import { CanvasStudy, hash, prog, type Frame, type Painter } from "./stage";

export const BG = "#1b1a42";
const INK = "#ffd76a";
const WORDS = ["Letterform", "Alignments", "Hierarchies", "Kerning"];
const ROWS = [0.2, 0.5, 0.8, 0.36]; // vertical centre of the line for each word
const HOLD = 1.9; // seconds per word, including the change
const SPREAD = 0.42; // middle letters go first, the ends last
const MOVE = 0.11; // the move itself is a blink
const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

type Slot = { ch: string; x: number; y: number };

export function create(): Painter {
  // hover flips, keyed by slot index
  const flips = new Map<number, number>();
  let lastHover = -1;

  return ({ ctx, w, h, t, p, fonts }: Frame) => {
    ctx.fillStyle = BG;
    ctx.fillRect(0, 0, w, h);
    ctx.font = `700 100px ${fonts.sans}`;
    const fs = Math.min(h * 0.24, (w * 0.86 * 100) / ctx.measureText("Hierarchies").width);
    ctx.font = `700 ${fs}px ${fonts.sans}`;
    ctx.textBaseline = "middle";
    ctx.fillStyle = INK;

    const slots = (k: number): Slot[] => {
      const word = WORDS[k % WORDS.length];
      const total = ctx.measureText(word).width;
      let x = (w - total) / 2;
      const y = h * ROWS[k % ROWS.length];
      return [...word].map((ch) => {
        const cw = ctx.measureText(ch).width;
        const s = { ch, x: x + cw / 2, y };
        x += cw;
        return s;
      });
    };

    const k = Math.floor(t / HOLD);
    const local = t - k * HOLD;
    const from = slots(k);
    const to = slots(k + 1);
    const changeAt = HOLD - (SPREAD + MOVE + 0.05);
    const n = Math.max(from.length, to.length);
    const rand = (i: number, step: number) => ALPHA[Math.floor(hash(i * 13 + step, k) * ALPHA.length)];

    let hover = -1;
    for (let i = 0; i < n; i++) {
      const a = from[Math.min(i, from.length - 1)];
      const b = to[Math.min(i, to.length - 1)];
      const mid = (to.length - 1) / 2;
      const delay = (Math.abs(i - mid) / Math.max(1, mid)) * SPREAD;
      const m = prog(local, changeAt + delay, MOVE);
      // letters that will merge into the end ride along with the last real letter
      if (i >= from.length && m <= 0) continue;
      if (i >= to.length && m >= 1) continue;

      let ch = a.ch;
      let x = a.x;
      let y = a.y;
      if (m > 0) {
        const e = m * m * (3 - 2 * m);
        x = a.x + (b.x - a.x) * e;
        y = a.y + (b.y - a.y) * e;
        ch = m >= 1 ? b.ch : rand(i, Math.floor(t * 30));
      }
      // a few random glyphs right after landing
      const after = local - (changeAt + delay + MOVE);
      if (m >= 1 && after < 0.12) ch = rand(i + 5, Math.floor(t * 30));

      // pointer: a resting letter flips through two strangers and comes back
      const resting = m <= 0 || m >= 1;
      if (resting && p.inside && Math.abs(p.x - x) < fs * 0.3 && Math.abs(p.y - y) < fs * 0.45) hover = i;
      const f = flips.get(i);
      if (f !== undefined) {
        const age = t - f;
        if (age < 0.18) ch = rand(i + 31, age < 0.09 ? 1 : 2);
        else flips.delete(i);
      }
      ctx.fillText(ch, x - ctx.measureText(ch).width / 2, y);
    }
    if (hover !== lastHover && hover >= 0 && !flips.has(hover)) flips.set(hover, t);
    lastHover = hover;
  };
}

export default function FlipRows() {
  return (
    <CanvasStudy
      create={create}
      background={BG}
      still={0.5}
      label="A butter-yellow word on indigo keeps becoming the next word; each letter flips and jumps to a new row, and hovering a letter makes it flip."
    />
  );
}
Prompt

Build "Flipping letters", a text animation. What it does: A butter-yellow word on indigo becomes the next word as each letter flips through strangers and jumps to a new row; hovering a letter makes it flip. 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).