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