Baktiyar Ablaikhan

Flipbook drop

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

At ten frames a second the letters of 'Motion' fall, stretch and squash with a lagging hard shadow, then inflate and pop into a ring of dashes.

Code

"use client";

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

export const BG = "#ffffff";
const WORD = "Motion";
const CYCLE = 4.8;
const FPS = 10;
// [letters, shadow]
const PAIRS: [string, string][] = [
  ["#ec3445", "#1b1b1b"],
  ["#2f3be8", "#ff7a3c"],
  ["#1b1b1b", "#ffcf2e"],
  ["#ff4fa0", "#2f3be8"],
  ["#14a66e", "#1b1b1b"],
  ["#ff7a3c", "#6a2be8"],
];

/** Fall, stretch, squash and settle for one letter; returns [dy, sx, sy] (dy in fractions of h). */
function drop(local: number): [number, number, number] {
  const fall = prog(local, 0, 0.32);
  if (fall < 1) {
    const k = easeIn(fall);
    return [-(1 - k) * 0.85, 0.82, 1.32];
  }
  const s = local - 0.32;
  if (s < 0.45) {
    const damp = Math.exp(-s * 9) * Math.cos(s * 22);
    return [0, 1 + 0.32 * damp, 1 - 0.3 * damp];
  }
  return [0, 1, 1];
}

export function create(): Painter {
  return ({ ctx, w, h, t: raw, fonts }: Frame) => {
    const t = Math.floor(raw * FPS) / FPS; // flipbook stepping
    ctx.fillStyle = BG;
    ctx.fillRect(0, 0, w, h);
    const cycle = Math.floor(t / CYCLE);
    const u = t - cycle * CYCLE;
    const [fill, shade] = PAIRS[cycle % PAIRS.length];
    const next = PAIRS[(cycle + 1) % PAIRS.length][0];

    ctx.font = `800 100px ${fonts.sans}`;
    const tight = 0.84;
    const raw100 = [...WORD].reduce((a, c) => a + ctx.measureText(c).width * tight, 0);
    const fs = Math.min(h * 0.42, (w * 0.62 * 100) / raw100);
    ctx.font = `800 ${fs}px ${fonts.sans}`;
    ctx.textBaseline = "alphabetic";
    const widths = [...WORD].map((c) => ctx.measureText(c).width);
    const total = widths.reduce((a, b) => a + b * tight, 0);
    const base = h / 2 + fs * 0.36;
    const sh = { x: -fs * 0.055, y: fs * 0.04 };

    const inflate = easeIn(prog(u, 2.2, 1.35)) ** 1.6;
    const popAt = 3.55;
    const scale = 1 + inflate * 0.7;

    if (u < popAt) {
      ctx.save();
      ctx.translate(w / 2, h / 2);
      ctx.scale(scale, scale);
      ctx.translate(-w / 2, -h / 2);
      for (const layer of [0, 1]) {
        let x = (w - total) / 2;
        [...WORD].forEach((c, i) => {
          const start = 0.1 + i * 0.2 + (layer === 0 ? 0.12 : 0); // shadows arrive a beat behind
          const local = u - start;
          const cw = widths[i];
          if (local >= 0) {
            const [dy, sx, sy] = drop(local);
            ctx.save();
            ctx.translate(x + cw / 2 + (layer === 0 ? sh.x : 0), base + dy * h + (layer === 0 ? sh.y : 0));
            ctx.scale(sx, sy);
            ctx.fillStyle = layer === 0 ? shade : fill;
            ctx.fillText(c, -cw / 2, 0);
            ctx.restore();
          }
          x += cw * tight;
        });
      }
      ctx.restore();
    } else {
      // pop: a ring of small dashes in the next colour flies outward
      const k = prog(u, popAt, 0.55);
      if (k < 1) {
        const R0 = (total * scale) / 2;
        const R = R0 * 0.6 + easeOut(k) * w * 0.45;
        const len = fs * 0.22 * (1 - k * 0.7);
        ctx.strokeStyle = next;
        ctx.lineCap = "round";
        ctx.lineWidth = Math.max(2, fs * 0.05);
        const N = 26;
        for (let i = 0; i < N; i++) {
          const a = (i / N) * Math.PI * 2;
          const cx = Math.cos(a);
          const cy = Math.sin(a) * 0.62;
          ctx.beginPath();
          ctx.moveTo(w / 2 + cx * R, h / 2 + cy * R);
          ctx.lineTo(w / 2 + cx * (R + len), h / 2 + cy * (R + len));
          ctx.stroke();
        }
      }
    }
  };
}

export default function FlipbookMotion() {
  return (
    <CanvasStudy
      create={create}
      background={BG}
      still={2}
      label="Chunky letters of 'Motion' with a hard drop shadow fall in at ten frames a second, squash, inflate and pop into a ring of dashes."
    />
  );
}
Prompt

Build "Flipbook drop", a text animation. What it does: At ten frames a second the letters of 'Motion' fall, stretch and squash with a lagging hard shadow, then inflate and pop into a ring of dashes. 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).