Baktiyar Ablaikhan

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