Baktiyar Ablaikhan

Pixel Scan Word

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

A scanner band sweeps across the word bababooey, breaking the letters it covers into coloured pixel blocks; the pointer steers the band.

Code

"use client";

import { approach, clamp, makeCanvas, useStage, type Scene } from "./stage";

/*
 * "bababooey" in a crisp serif with a scanner band passing over it: inside the
 * band the letters break into coloured pixel blocks, coarsest at the centre of
 * the band and resolving back to type at its edges. The pointer steers it.
 */

const WORD = "bababooey";
const BG = "#fafdff";
const INK = "#16161d";
const STOPS: [number, number, number][] = [
  [124, 58, 237],
  [236, 72, 153],
  [59, 130, 246],
  [124, 58, 237],
];

function grad(u: number): string {
  const x = ((u % 1) + 1) % 1;
  const f = x * (STOPS.length - 1);
  const i = Math.floor(f);
  const k = f - i;
  const a = STOPS[i];
  const b = STOPS[i + 1];
  return `rgb(${(a[0] + (b[0] - a[0]) * k) | 0},${(a[1] + (b[1] - a[1]) * k) | 0},${(a[2] + (b[2] - a[2]) * k) | 0})`;
}

function createScene(): Scene {
  let text: HTMLCanvasElement | null = null;
  let alpha: Uint8ClampedArray | null = null;
  let W = 0;
  let H = 0;
  let x0 = 0;
  let x1 = 0;
  let band = 0.5;

  return {
    resize(w, h, dpr) {
      const r = Math.min(2, dpr);
      W = w;
      H = h;
      const size = Math.min(h * 0.3, w * 0.135);
      const font = `400 ${size}px "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif`;
      const t = makeCanvas(w * r, h * r);
      t.ctx.setTransform(r, 0, 0, r, 0, 0);
      t.ctx.font = font;
      t.ctx.textAlign = "center";
      t.ctx.textBaseline = "middle";
      t.ctx.fillStyle = INK;
      t.ctx.fillText(WORD, w / 2, h / 2);
      text = t.c;
      const tw = t.ctx.measureText(WORD).width;
      x0 = w / 2 - tw / 2;
      x1 = w / 2 + tw / 2;
      // a 1-css-px alpha map for sampling
      const m = makeCanvas(w, h);
      m.ctx.font = font;
      m.ctx.textAlign = "center";
      m.ctx.textBaseline = "middle";
      m.ctx.fillStyle = "#000";
      m.ctx.fillText(WORD, w / 2, h / 2);
      const d = m.ctx.getImageData(0, 0, Math.max(1, Math.round(w)), Math.max(1, Math.round(h))).data;
      alpha = new Uint8ClampedArray(d.length / 4);
      for (let i = 0; i < alpha.length; i++) alpha[i] = d[i * 4 + 3];
    },
    frame({ ctx, w, h, t, dt, p }) {
      ctx.fillStyle = BG;
      ctx.fillRect(0, 0, w, h);
      if (!text || !alpha) return;
      ctx.drawImage(text, 0, 0, w, h);

      // band position: ping-pong sweep, or follow the pointer
      const sweep = 0.5 - 0.5 * Math.cos((t / 3.2) * Math.PI * 2);
      const auto = x0 - w * 0.06 + (x1 - x0 + w * 0.12) * sweep;
      band = dt === 0 ? (p.inside ? p.x : auto) : approach(band, p.inside ? p.x : auto, p.inside ? 10 : 60, dt);
      const half = Math.min(w * 0.11, h * 0.32);
      const left = Math.max(0, band - half);
      const right = Math.min(w, band + half);
      const top = h * 0.2;
      const bottom = h * 0.8;
      ctx.fillStyle = BG;
      ctx.fillRect(left, top, right - left, bottom - top);

      const am = alpha;
      const aw = Math.round(W);
      const sample = (x: number, y: number) => {
        const xi = Math.min(aw - 1, Math.max(0, Math.round(x)));
        const yi = Math.min(Math.round(H) - 1, Math.max(0, Math.round(y)));
        return am[yi * aw + xi] / 255;
      };
      const maxBlock = Math.max(3, h * 0.022);
      // columns of blocks, coarse at the band centre
      let x = left;
      while (x < right) {
        const u = 1 - Math.abs(x - band) / half;
        const bs = Math.max(1.5, 1.5 + (maxBlock - 1.5) * clamp(u * 1.4) ** 1.2);
        for (let y = top; y < bottom; y += bs) {
          const a = sample(x + bs / 2, y + bs / 2);
          if (a > 0.3) {
            ctx.fillStyle = u < 0.12 ? INK : grad((y - top) / (bottom - top) * 0.8 + t * 0.25 + x * 0.002);
            ctx.fillRect(x, y, bs + 0.3, bs + 0.3);
          } else if (a > 0.05 && ((x * 7 + y * 13 + Math.floor(t * 12)) | 0) % 5 === 0) {
            ctx.fillStyle = grad(t * 0.25 + 0.4);
            ctx.globalAlpha = 0.5;
            ctx.fillRect(x, y, bs, bs);
            ctx.globalAlpha = 1;
          }
        }
        x += bs;
      }
      // scan line
      const g = ctx.createLinearGradient(0, top, 0, bottom);
      g.addColorStop(0, "rgba(124,58,237,0)");
      g.addColorStop(0.5, "rgba(124,58,237,0.35)");
      g.addColorStop(1, "rgba(124,58,237,0)");
      ctx.fillStyle = g;
      ctx.fillRect(band - 0.75, top, 1.5, bottom - top);
    },
  };
}

export default function PixelScan() {
  const ref = useStage(createScene, 1.1);
  return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#fafdff]" />;
}
Prompt

Build "Pixel Scan Word", a text animation. What it does: A scanner band sweeps across the word bababooey, breaking the letters it covers into coloured pixel blocks; the pointer steers the band. 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).