Scanline letters
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
Three-letter words built from fat yellow scanline dashes unfurl from a dotted vertical line letter by letter and fold back.
Code
"use client";
import { CanvasStudy, easeInOut, makeCanvas, prog, type Frame, type Painter } from "./stage";
export const BG = "#ffffff";
const WORDS = ["LOL", "VLT", "ART", "TOY"];
const ROWS = 11;
const DUR = 4.2;
type Run = [number, number]; // x0, x1 in units of the row height, relative to the letter's left edge
type Glyph = { rows: Run[][]; width: number };
function rasterize(ch: string, font: string): Glyph {
const S = 12; // px per row while sampling
const H = ROWS * S;
const W = Math.ceil(H * 1.6);
const { ctx } = makeCanvas(W, H);
ctx.font = `800 ${H}px ${font}`;
const asc = ctx.measureText("H").actualBoundingBoxAscent || H * 0.72;
ctx.font = `800 ${(H * H * 0.99) / asc}px ${font}`;
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#000";
const m = ctx.measureText(ch);
ctx.fillText(ch, (W - m.width) / 2, H);
const data = ctx.getImageData(0, 0, W, H).data;
let minX = W;
let maxX = 0;
const rows: Run[][] = [];
for (let r = 0; r < ROWS; r++) {
const y = Math.floor(r * S + S / 2);
const runs: Run[] = [];
let start = -1;
for (let x = 0; x <= W; x++) {
const on = x < W && data[(y * W + x) * 4 + 3] > 128;
if (on && start < 0) start = x;
if (!on && start >= 0) {
runs.push([start, x]);
minX = Math.min(minX, start);
maxX = Math.max(maxX, x);
start = -1;
}
}
rows.push(runs);
}
if (minX > maxX) return { rows, width: 4 };
return { rows: rows.map((runs) => runs.map(([a, b]) => [(a - minX) / S, (b - minX) / S] as Run)), width: (maxX - minX) / S };
}
export function create(): Painter {
const glyphs = new Map<string, Glyph>();
let fontKey = "";
return ({ ctx, w, h, t, fonts }: Frame) => {
const fk = `${fonts.sans}|${typeof document !== "undefined" ? document.fonts?.status : ""}`;
if (fk !== fontKey) {
fontKey = fk;
glyphs.clear();
}
ctx.fillStyle = BG;
ctx.fillRect(0, 0, w, h);
const n = Math.floor(t / DUR);
const u = t - n * DUR;
const word = [...WORDS[n % WORDS.length]];
const gs = word.map((ch) => {
let g = glyphs.get(ch);
if (!g) {
g = rasterize(ch, fonts.sans);
glyphs.set(ch, g);
}
return g;
});
const gap = 1.1;
const unitsW = gs.reduce((a, g) => a + g.width, 0) + gap * (gs.length - 1);
const unit = Math.min((h * 0.62) / ROWS, (w * 0.74) / unitsW);
const top = h / 2 - (ROWS * unit) / 2;
const cx = w / 2;
const thick = unit * 0.62;
// per-letter row order: first wipes down, last wipes up, middle pinched from both ends
const order = (li: number, r: number) => {
if (li === 0) return r / (ROWS - 1);
if (li === gs.length - 1) return 1 - r / (ROWS - 1);
return 1 - Math.abs(r - (ROWS - 1) / 2) / ((ROWS - 1) / 2);
};
const open = (li: number, r: number) => {
const unfurl = easeInOut(prog(u, 0.35 + li * 0.5 + order(li, r) * 0.4, 0.3));
const fold = easeInOut(prog(u, 3.0 + (gs.length - 1 - li) * 0.22 + (1 - order(li, r)) * 0.2, 0.25));
return unfurl * (1 - fold);
};
ctx.lineCap = "round";
ctx.lineWidth = thick;
let left = cx - (unitsW * unit) / 2;
gs.forEach((g, li) => {
g.rows.forEach((runs, r) => {
const k = open(li, r);
const y = top + (r + 0.5) * unit;
const hue = 46 + 9 * Math.sin(t * 1.8 + r * 0.7 + li * 1.3);
const light = 54 + 7 * Math.sin(t * 1.3 + r * 0.5 - li);
ctx.strokeStyle = `hsl(${hue.toFixed(1)} 100% ${light.toFixed(1)}%)`;
runs.forEach(([a, b]) => {
const x0 = cx + (left + a * unit - cx) * k;
const x1 = cx + (left + b * unit - cx) * k;
ctx.beginPath();
ctx.moveTo(x0 + thick * 0.5 * k, y);
ctx.lineTo(Math.max(x0 + thick * 0.5 * k, x1 - thick * 0.5 * k), y);
ctx.stroke();
});
});
left += (g.width + gap) * unit;
});
};
}
export default function ScanlineWords() {
return (
<CanvasStudy
create={create}
background={BG}
still={2.6}
label="Three-letter words built from fat yellow scanline dashes unfurl from a dotted vertical line, rest, and fold back."
/>
);
}
Prompt
Build "Scanline letters", a text animation. What it does: Three-letter words built from fat yellow scanline dashes unfurl from a dotted vertical line letter by letter and fold back. 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).