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