Baktiyar Ablaikhan

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