Baktiyar Ablaikhan

Rolling shutter

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

'Design is thinking' shakes into place in lagging bands, inverts and lifts off, then a stack of 'made visual' scrolls up and tears at every step.

Code

"use client";

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

export const BG = "#f2efe6";
const PAPER = "#f2efe6";
const SKY = "#13b2d4";
const CYCLE = 7.6;
const WORDS = ["Design", "is", "thinking"];

type Img = { c: HTMLCanvasElement; w: number; h: number };

export function create(): Painter {
  const cache = new Map<string, Img>();
  let cacheKey = "";

  const text = (s: string, color: string, fs: number, font: string, dpr: number): Img => {
    const key = `${s}|${color}`;
    const hit = cache.get(key);
    if (hit) return hit;
    const probe = makeCanvas(1, 1).ctx;
    probe.font = `600 ${fs}px ${font}`;
    const w = Math.ceil(probe.measureText(s).width + fs * 0.1);
    const h = Math.ceil(fs * 1.25);
    const { c, ctx } = makeCanvas(w * dpr, h * dpr);
    ctx.scale(dpr, dpr);
    ctx.font = `600 ${fs}px ${font}`;
    ctx.textBaseline = "middle";
    ctx.fillStyle = color;
    ctx.fillText(s, fs * 0.05, h / 2);
    const img = { c, w, h };
    cache.set(key, img);
    return img;
  };

  /** Draw an image cut into horizontal bands, each with its own x offset. */
  const banded = (ctx: CanvasRenderingContext2D, img: Img, x: number, y: number, bands: number, off: (b: number) => number, scale = 1) => {
    const bh = img.h / bands;
    const sh = img.c.height / bands;
    for (let b = 0; b < bands; b++) {
      ctx.drawImage(img.c, 0, b * sh, img.c.width, sh + 0.5, x + off(b), y + b * bh * scale, img.w * scale, bh * scale + 0.5);
    }
  };

  return ({ ctx, w, h, t, fonts, dpr }: Frame) => {
    ctx.font = `600 100px ${fonts.sans}`;
    const fs1 = Math.min(h * 0.17, (w * 0.8 * 100) / (ctx.measureText("Design is thinking").width * 1.1));
    const fs2 = Math.min(h * 0.36, (w * 0.92 * 100) / ctx.measureText("made visual").width);
    const ck = `${w}|${h}|${dpr}|${fonts.sans}`;
    if (ck !== cacheKey) {
      cache.clear();
      cacheKey = ck;
    }
    const u = t % CYCLE;

    if (u < 2.8) {
      const inverted = u >= 1.8;
      ctx.fillStyle = inverted ? SKY : PAPER;
      ctx.fillRect(0, 0, w, h);
      const color = inverted ? PAPER : SKY;
      const imgs = WORDS.map((s) => text(s, color, fs1, fonts.sans, dpr));
      const gap = fs1 * 0.25;
      const total = imgs.reduce((a, im) => a + im.w, 0) + gap * (imgs.length - 1);
      const swell = 1 + 0.08 * easeOut(prog(u, 1.8, 0.5));
      const lift = easeIn(prog(u, 2.25, 0.55));
      let x = w / 2 - (total * swell) / 2;
      imgs.forEach((im, k) => {
        const start = 0.1 + k * 0.28;
        const shake = (tau: number) => {
          if (tau < 0) return w; // not arrived yet
          return fs1 * 1.1 * Math.exp(-tau * 6) * Math.sin(tau * 32 + 1.2);
        };
        if (u < start) {
          x += (im.w + gap) * swell;
          return;
        }
        const yTop = h / 2 - (im.h * swell) / 2 - lift * h * 0.95;
        banded(
          ctx,
          im,
          x,
          yTop,
          14,
          (b) => shake(u - start - b * 0.014) + (lift > 0 ? Math.sin(b * 1.7) * lift * fs1 * 0.05 : 0),
          swell,
        );
        x += (im.w + gap) * swell;
      });
      return;
    }

    // stack of "made visual"
    const lastBeats = u > 6.3;
    ctx.fillStyle = lastBeats ? SKY : PAPER;
    ctx.fillRect(0, 0, w, h);
    const im = text("made visual", lastBeats ? PAPER : SKY, fs2, fonts.sans, dpr);
    const rowH = fs2 * 0.98;
    const local = u - 2.8;
    const stepLen = 0.55;
    const step = Math.floor(local / stepLen);
    const frac = local - step * stepLen;
    const moveK = easeInOut(Math.min(1, frac / 0.28));
    const scroll = step + moveK;
    const tear = (1 - Math.min(1, frac / 0.42)) ** 2;
    const x = (w - im.w) / 2;
    const baseY = h * 0.62 - scroll * rowH;
    for (let r = 0; r < 14; r++) {
      const y = baseY + r * rowH - (im.h - rowH) / 2;
      if (y > h || y + im.h < 0) continue;
      banded(ctx, im, x, y, 12, (b) => (hash(b + r * 31, step) - 0.5) * 2 * fs2 * 0.42 * tear * (b % 2 ? 1 : -0.7));
    }
  };
}

export default function RollingShutter() {
  return (
    <CanvasStudy
      create={create}
      background={BG}
      still={4}
      label="'Design is thinking' shakes in through a rolling shutter, inverts and lifts off; a stack of 'made visual' scrolls up, tearing into bands at each step."
    />
  );
}
Prompt

Build "Rolling shutter", a text animation. What it does: 'Design is thinking' shakes into place in lagging bands, inverts and lifts off, then a stack of 'made visual' scrolls up and tears at every step. 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).