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