Baktiyar Ablaikhan

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