Staggered Block Wave
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
Five dark blocks slide between a left and right stop a beat apart, so the motion falls through the stack as a wave over an orange shape traced from their leading edges; hover tints the rows.
Code
"use client";
import { approach, clamp, hex, mixRGB, rgbStr, smooth, useStage, type RGB, type Scene } from "./stage";
/*
* Five dark blocks sliding between a left and a right stop, each row a beat off
* the next so the movement runs through the stack as a wave. The orange behind
* them is not a shape: it is the convex hull of all twenty block corners.
*/
const SIZE = 720;
const BW = 214;
const BH = 121;
const PITCH = 132.5;
const INSET = 46;
const TRAVERSE = 0.306;
const DWELL = 0.316;
const CYCLE = 2 * (TRAVERSE + DWELL);
const STAGGER = -0.083;
const ROWS = 5;
const TOP = (SIZE - (PITCH * (ROWS - 1) + BH)) / 2;
const INK = hex("#141416");
const COOL = hex("#2336d8");
const HOT = hex("#ff2d7a");
const ORANGE = "#ff5a1f";
const easeMove = (k: number) => 1 - (1 - smooth(k)) ** 2;
const easeSpeed = (k: number) => {
const s = smooth(k);
const ds = 6 * k * (1 - k);
return 2 * (1 - s) * ds;
};
/** Position (0 = left stop, 1 = right stop), speed and destination of row r. */
function rowState(r: number, t: number) {
const c = (((t / CYCLE - STAGGER * r) % 1) + 1) % 1;
const half = c < 0.5 ? 0 : 1;
const local = (c - half * 0.5) * CYCLE;
const k = clamp(local / TRAVERSE);
const e = easeMove(k);
const pos = half === 0 ? e : 1 - e;
const moving = k < 1;
// label names the destination and flips on arrival
const goingRight = half === 0 ? moving : !moving;
return { pos, speed: moving ? easeSpeed(k) / TRAVERSE : 0, label: goingRight ? "RIGHT" : "LEFT" };
}
function hull(pts: [number, number][]) {
const p = pts.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const cross = (o: number[], a: number[], b: number[]) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower: [number, number][] = [];
for (const q of p) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], q) <= 0) lower.pop();
lower.push(q);
}
const upper: [number, number][] = [];
for (let i = p.length - 1; i >= 0; i--) {
const q = p[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], q) <= 0) upper.pop();
upper.push(q);
}
return lower.slice(0, -1).concat(upper.slice(0, -1));
}
const bezier3 = (a: RGB, b: RGB, c: RGB, k: number): RGB => mixRGB(mixRGB(a, b, k), mixRGB(b, c, k), k);
function createScene(): Scene {
const hot = new Array(ROWS).fill(0);
const travel = SIZE - 2 * INSET - BW;
const corners = (t: number) => {
const pts: [number, number][] = [];
for (let r = 0; r < ROWS; r++) {
const x = INSET + rowState(r, t).pos * travel;
const y = TOP + r * PITCH;
pts.push([x, y], [x + BW, y], [x, y + BH], [x + BW, y + BH]);
}
return pts;
};
return {
frame({ ctx, w, h, dpr, t, dt, p }) {
ctx.fillStyle = "#ebe8e1";
ctx.fillRect(0, 0, w, h);
const S = Math.min(h / SIZE, w / SIZE) * 0.94;
const ox = (w - SIZE * S) / 2;
const oy = (h - SIZE * S) / 2;
ctx.setTransform(S * dpr, 0, 0, S * dpr, ox * dpr, oy * dpr);
const lx = (p.x - ox) / S;
const ly = (p.y - oy) / S;
// the accent: hull of every corner
const hl = hull(corners(t));
ctx.beginPath();
hl.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.closePath();
ctx.fillStyle = ORANGE;
ctx.fill();
// a hairline wake of the hull, only while the stack is moving
let motion = 0;
for (let r = 0; r < ROWS; r++) motion = Math.max(motion, rowState(r, t).speed);
if (motion > 0.05) {
const wk = hull(corners(t - 0.18));
ctx.beginPath();
wk.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.closePath();
ctx.strokeStyle = ORANGE;
ctx.globalAlpha = clamp(motion / 3) * 0.9;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.font = `600 15px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`;
ctx.textBaseline = "alphabetic";
for (let r = 0; r < ROWS; r++) {
const st = rowState(r, t);
const x = INSET + st.pos * travel;
const y = TOP + r * PITCH;
const over = p.inside && ly > y - 20 && ly < y + BH + 20 ? clamp(1 - Math.abs(lx - (x + BW / 2)) / 420) : 0;
hot[r] = approach(hot[r], over, 7, dt);
const sp = clamp(st.speed / 4);
// hue drift: a bend through three colours, quiet unless hovered
const k = hot[r] > 0.01 ? st.pos : sp;
const strength = 0.12 * sp + 0.85 * hot[r];
const col = mixRGB(INK, bezier3(INK, COOL, HOT, k), strength);
const css = rgbStr(col);
// smear back along the distance just covered, drawn before the block
if (st.speed > 0.4) {
const n = 7;
for (let j = n; j >= 1; j--) {
const px = INSET + rowState(r, t - j * 0.009).pos * travel;
ctx.globalAlpha = 0.16 * (1 - j / (n + 1));
ctx.fillStyle = css;
ctx.fillRect(px, y, BW, BH);
}
ctx.globalAlpha = 1;
}
ctx.fillStyle = css;
ctx.fillRect(x, y, BW, BH);
// the label rides the face on the same curve, flush in either corner
const lw = ctx.measureText(st.label).width;
const pad = 14;
ctx.fillStyle = "#f4f3ef";
ctx.fillText(st.label, x + pad + (BW - 2 * pad - lw) * st.pos, y + BH - pad);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
},
};
}
export default function BlockWave() {
const ref = useStage(createScene, 0.2);
return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#ebe8e1]" />;
}
Prompt
Build "Staggered Block Wave", a canvas animation. What it does: Five dark blocks slide between a left and right stop a beat apart, so the motion falls through the stack as a wave over an orange shape traced from their leading edges; hover tints the rows. 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).