Selected word
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
A word selected in a design tool splits into boxed letters, scatters, has letters inspected at huge scale, collapses to a dot and snaps back.
Code
"use client";
import { CanvasStudy, clamp, easeInOut, easeOutBack, hash, lerp, prog, type Frame, type Painter } from "./stage";
export const BG = "#ffe500";
const INK = "#121212";
const BLUE = "#1f63ff";
const WORD = "Select";
const DUR = 9.4;
const INSPECT = [1, 4, 0, 3]; // letters inspected one at a time
const INSPECT_AT = 2.5;
const INSPECT_LEN = 1.05;
const COLLAPSE_AT = INSPECT_AT + INSPECT.length * INSPECT_LEN + 0.15;
type Box = { x: number; y: number; w: number; h: number; rot: number };
function selection(ctx: CanvasRenderingContext2D, b: Box, alpha: number, label?: string, font?: string) {
if (alpha <= 0.01) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(b.x, b.y);
ctx.rotate(b.rot);
ctx.strokeStyle = BLUE;
ctx.lineWidth = 1.25;
ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h);
const s = 7;
for (const [cx, cy] of [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]) {
ctx.fillStyle = "#ffffff";
ctx.fillRect((cx * b.w) / 2 - s / 2, (cy * b.h) / 2 - s / 2, s, s);
ctx.strokeRect((cx * b.w) / 2 - s / 2, (cy * b.h) / 2 - s / 2, s, s);
}
if (label && font) {
ctx.font = `500 11px ${font}`;
const tw = ctx.measureText(label).width + 10;
ctx.fillStyle = BLUE;
ctx.fillRect(-tw / 2, b.h / 2 + 8, tw, 18);
ctx.fillStyle = "#ffffff";
ctx.textBaseline = "middle";
ctx.fillText(label, -tw / 2 + 5, b.h / 2 + 17.5);
}
ctx.restore();
}
export function create(): Painter {
return ({ ctx, w, h, t, fonts }: Frame) => {
ctx.fillStyle = BG;
ctx.fillRect(0, 0, w, h);
const u = t % DUR;
const cyc = Math.floor(t / DUR);
ctx.font = `700 100px ${fonts.sans}`;
const fs = Math.min(h * 0.3, (w * 0.62 * 100) / ctx.measureText(WORD).width);
ctx.font = `700 ${fs}px ${fonts.sans}`;
ctx.textBaseline = "alphabetic";
const letters = [...WORD];
const ms = letters.map((c) => ctx.measureText(c));
const total = ms.reduce((a, m) => a + m.width, 0);
const asc = fs * 0.74;
const base = h / 2 + asc / 2;
let x = (w - total) / 2;
const home = letters.map((_, i) => {
const cx = x + ms[i].width / 2;
x += ms[i].width;
return cx;
});
const split = easeInOut(prog(u, 1.0, 0.4));
const scatter = easeInOut(prog(u, 1.4, 0.9));
const collapse = easeInOut(prog(u, COLLAPSE_AT, 0.7));
const snap = easeOutBack(prog(u, COLLAPSE_AT + 0.85, 0.35));
const assembled = u >= COLLAPSE_AT + 0.85;
const boxes: Box[] = [];
const draws: { k: number; draw: () => void }[] = [];
let top = null as { i: number; box: Box; k: number } | null;
letters.forEach((c, i) => {
const m = ms[i];
const gh = (m.actualBoundingBoxAscent || asc) + (m.actualBoundingBoxDescent || 0);
const gyc = base - ((m.actualBoundingBoxAscent || asc) - (m.actualBoundingBoxDescent || 0)) / 2;
const sx = w * (0.14 + hash(i, cyc) * 0.72);
const sy = h * (0.2 + hash(i + 7, cyc) * 0.6);
const srot = (hash(i + 3, cyc) - 0.5) * 0.9;
let px = lerp(home[i], sx, scatter);
let py = lerp(gyc, sy, scatter);
let rot = srot * scatter;
let scale = 1;
// inspection
const slot = INSPECT.indexOf(i);
let k = 0;
if (slot >= 0) {
const s0 = INSPECT_AT + slot * INSPECT_LEN;
k = easeInOut(prog(u, s0, 0.3)) * (1 - easeInOut(prog(u, s0 + 0.72, 0.3)));
const big = (h * 0.62) / Math.max(gh, 1);
px = lerp(px, w / 2, k);
py = lerp(py, h / 2, k);
rot = lerp(rot, 0, k);
scale = lerp(1, big, k);
}
// collapse to a dot, then snap back as the whole word
if (collapse > 0 && !assembled) {
px = lerp(px, w / 2, collapse);
py = lerp(py, h / 2, collapse);
scale *= 1 - collapse;
}
if (assembled) {
px = home[i];
py = gyc;
rot = 0;
scale = Math.max(0, snap);
}
const box: Box = { x: px, y: py, w: (m.width + 6) * scale, h: (gh + 8) * scale, rot };
boxes.push(box);
if (k > 0.02 && (!top || k > top.k)) top = { i, box, k };
if (scale > 0.003) {
draws.push({
k,
draw: () => {
ctx.save();
ctx.translate(px, py);
ctx.rotate(rot);
ctx.scale(scale, scale);
ctx.fillStyle = INK;
ctx.fillText(c, -m.width / 2, base - gyc);
ctx.restore();
},
});
}
});
// the inspected letter sits on top
draws.sort((a, b) => a.k - b.k).forEach((d) => d.draw());
// selection chrome
const wordSel = Math.max(1 - split, assembled ? clamp((u - COLLAPSE_AT - 1.1) / 0.2) : 0);
if (wordSel > 0) {
const gh = asc + fs * 0.06;
selection(ctx, { x: w / 2, y: base - asc / 2 + fs * 0.03, w: total + 16, h: gh + 16, rot: 0 }, wordSel, `${WORD} · ${Math.round(total)} × ${Math.round(gh)}`, fonts.mono);
}
const letterSel = split * (1 - collapse);
if (letterSel > 0 && !assembled) {
boxes.forEach((b, i) => {
if (top && top.i === i) return;
selection(ctx, b, letterSel * (top ? 0.45 : 1));
});
if (top) {
const { box, i } = top;
selection(ctx, box, letterSel, `${letters[i]} W ${Math.round(box.w)} H ${Math.round(box.h)}`, fonts.mono);
}
}
if (collapse > 0.9 && !assembled) {
ctx.fillStyle = BLUE;
ctx.beginPath();
ctx.arc(w / 2, h / 2, 4, 0, Math.PI * 2);
ctx.fill();
}
};
}
export default function SelectedWord() {
return (
<CanvasStudy
create={create}
background={BG}
still={1.9}
label="A word selected in a design tool splits into boxed letters, scatters, has each letter inspected at huge scale, collapses to a dot and snaps back."
/>
);
}
Prompt
Build "Selected word", a text animation. What it does: A word selected in a design tool splits into boxed letters, scatters, has letters inspected at huge scale, collapses to a dot and snaps back. 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).