"use client"; import * as React from "react"; /** * BitHeadline: a headline whose letters are made of running binary digits. * * - "solid" lines condense from bits into massive dark type, with digits still moving inside. * - "bits" lines stay built from digits only, so they read lighter. * * The real text stays in the DOM (transparent once the canvas is drawn), so it remains * selectable, accessible and visible without JavaScript. */ export type BitLine = { readonly text: string; readonly tone: "solid" | "bits"; }; type Measured = { readonly text: string; readonly tone: BitLine["tone"]; readonly x: number; readonly baseline: number; /** Vertical extent of the glyphs, which can exceed a tight line box. */ readonly top: number; readonly height: number; readonly width: number; readonly font: string; readonly letterSpacing: string; }; const DARK = "15, 23, 42"; // slate-900 const LIGHT = "255, 255, 255"; const INTRO_MS = 1600; const FRAME_MS = 1000 / 30; const POINTER_RADIUS = 170; const MIN_CELL = 6; const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3); const clamp01 = (n: number): number => Math.min(1, Math.max(0, n)); const makeSprite = ( digit: string, color: string, cell: number, dpr: number, ): HTMLCanvasElement => { const sprite = document.createElement("canvas"); sprite.width = Math.ceil(cell * dpr); sprite.height = Math.ceil(cell * dpr); const sctx = sprite.getContext("2d"); if (sctx) { sctx.scale(dpr, dpr); const mono = getComputedStyle(document.documentElement) .getPropertyValue("--font-jetbrains-mono") .trim() || "ui-monospace"; sctx.font = `700 ${Math.round(cell * 1.15)}px ${mono}, ui-monospace, Menlo, monospace`; sctx.textAlign = "center"; sctx.textBaseline = "middle"; sctx.fillStyle = color; sctx.fillText(digit, cell / 2, cell / 2 + 0.5); } return sprite; }; export const BitHeadline: React.FC<{ lines: readonly BitLine[]; className?: string; /** Render as this heading level. */ as?: "h1" | "h2"; /** Light type on a dark surface. */ invert?: boolean; }> = ({ lines, className = "", as: Tag = "h1", invert = false }) => { const wrapRef = React.useRef(null); const INK = invert ? LIGHT : DARK; const PAPER = invert ? DARK : LIGHT; const canvasRef = React.useRef(null); const [painted, setPainted] = React.useState(false); React.useEffect(() => { const wrap = wrapRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!wrap || !canvas || !ctx) return; const off = document.createElement("canvas"); const offCtx = off.getContext("2d"); if (!offCtx) return; const reducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; // Touch screens have no pointer to light the digits up, so a spot wanders on its own. const noHover = window.matchMedia("(hover: none)").matches; let measured: Measured[] = []; let width = 0; let height = 0; let dpr = 1; let cell = 12; let wash = 0.07; let cols = 0; let rows = 0; let bits = new Uint8Array(0); let phase = new Float32Array(0); let speed = new Float32Array(0); let sprites: { light: HTMLCanvasElement[]; dark: HTMLCanvasElement[]; } | null = null; let raf = 0; let startedAt = 0; let lastFrame = 0; let pointer: { x: number; y: number } | null = null; let disposed = false; const measure = () => { const box = wrap.getBoundingClientRect(); dpr = Math.min(window.devicePixelRatio || 1, 2); width = box.width; height = box.height; for (const target of [canvas, off]) { target.width = Math.ceil(width * dpr); target.height = Math.ceil(height * dpr); } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); offCtx.setTransform(dpr, 0, 0, dpr, 0, 0); const spans = Array.from( wrap.querySelectorAll("[data-bit-line]"), ); measured = spans.map((span, index) => { const rect = span.getBoundingClientRect(); const style = getComputedStyle(span); const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; ctx.font = font; const metrics = ctx.measureText("Hg"); const ascent = metrics.fontBoundingBoxAscent; const descent = metrics.fontBoundingBoxDescent; const lineTop = rect.top - box.top; const baseline = lineTop + (rect.height - (ascent + descent)) / 2 + ascent; return { text: span.textContent ?? "", tone: lines[index]?.tone ?? "solid", x: rect.left - box.left, top: baseline - ascent, height: ascent + descent, width: rect.width, baseline, font, letterSpacing: style.letterSpacing, }; }); const fontSize = parseFloat(getComputedStyle(wrap).fontSize) || 100; // Digits below about 6px are specks, not digits: on phones the cells stay // readable and the letters are built from fewer of them. cell = Math.round(Math.min(9, Math.max(MIN_CELL, fontSize / 19))); // Small type needs a stronger wash under the digits to keep its shape. wash = fontSize < 100 ? 0.24 : 0.07; cols = Math.ceil(width / cell); rows = Math.ceil(height / cell); bits = new Uint8Array(cols * rows); phase = new Float32Array(cols * rows); speed = new Float32Array(cols * rows); for (let i = 0; i < bits.length; i++) { bits[i] = Math.random() < 0.5 ? 0 : 1; phase[i] = Math.random() * Math.PI * 2; speed[i] = 0.6 + Math.random() * 1.8; } sprites = { light: [ makeSprite("0", `rgb(${PAPER})`, cell, dpr), makeSprite("1", `rgb(${PAPER})`, cell, dpr), ], dark: [ makeSprite("0", `rgb(${INK})`, cell, dpr), makeSprite("1", `rgb(${INK})`, cell, dpr), ], }; }; const setFont = (context: CanvasRenderingContext2D, line: Measured) => { context.font = line.font; context.textBaseline = "alphabetic"; context.textAlign = "left"; if ("letterSpacing" in context) { ( context as CanvasRenderingContext2D & { letterSpacing: string } ).letterSpacing = line.letterSpacing === "normal" ? "0px" : line.letterSpacing; } }; /** Digits over one line's box. `strength` scales the alpha, `set` picks the sprite color. */ const drawDigits = ( context: CanvasRenderingContext2D, line: Measured, set: HTMLCanvasElement[], seconds: number, base: number, swing: number, ) => { const firstRow = Math.max(0, Math.floor(line.top / cell)); const lastRow = Math.min( rows, Math.ceil((line.top + line.height) / cell), ); const firstCol = Math.max(0, Math.floor(line.x / cell)); const lastCol = Math.min(cols, Math.ceil((line.x + line.width) / cell)); // A soft diagonal scan band that travels across the headline. const scan = ((seconds * 0.16) % 1.6) * (width + height) - height * 0.3; for (let row = firstRow; row < lastRow; row++) { for (let col = firstCol; col < lastCol; col++) { const i = row * cols + col; const x = col * cell; const y = row * cell; let alpha = base + swing * (0.5 + 0.5 * Math.sin(seconds * (speed[i] ?? 1) + (phase[i] ?? 0))); const scanDistance = Math.abs(x + y - scan); if (scanDistance < 90) alpha += (1 - scanDistance / 90) * 0.45; if (pointer) { const d = Math.hypot(x - pointer.x, y - pointer.y); if (d < POINTER_RADIUS) alpha += (1 - d / POINTER_RADIUS) * 0.7; } if (alpha <= 0.02) continue; // Occasionally flip a bit so the field stays alive. if (Math.random() < 0.004) bits[i] = bits[i] === 1 ? 0 : 1; const sprite = set[bits[i] ?? 0]; if (!sprite) continue; context.globalAlpha = clamp01(alpha); context.drawImage(sprite, x, y, cell, cell); } } context.globalAlpha = 1; }; const paint = (now: number) => { if (!sprites) return; const seconds = now / 1000; const intro = reducedMotion ? 1 : easeOut(clamp01((now - startedAt) / INTRO_MS)); ctx.clearRect(0, 0, width, height); for (const line of measured) { offCtx.globalCompositeOperation = "source-over"; offCtx.clearRect(0, 0, width, height); if (line.tone === "solid") { // Letters start as pure bits and condense into solid ink. drawDigits( offCtx, line, sprites.dark, seconds, 0.75 * (1 - intro), 0.25 * (1 - intro), ); offCtx.globalCompositeOperation = "destination-in"; setFont(offCtx, line); offCtx.fillStyle = "#000"; offCtx.fillText(line.text, line.x, line.baseline); ctx.drawImage(off, 0, 0, width, height); setFont(ctx, line); ctx.fillStyle = `rgba(${INK}, ${intro})`; ctx.fillText(line.text, line.x, line.baseline); // Bright digits keep moving inside the solid letters. offCtx.globalCompositeOperation = "source-over"; offCtx.clearRect(0, 0, width, height); drawDigits( offCtx, line, sprites.light, seconds, 0.02 * intro, 0.16 * intro, ); offCtx.globalCompositeOperation = "destination-in"; setFont(offCtx, line); offCtx.fillText(line.text, line.x, line.baseline); ctx.drawImage(off, 0, 0, width, height); } else { // Stays built from digits only, on a faint wash so the letters keep their shape. setFont(ctx, line); ctx.fillStyle = `rgba(${INK}, ${wash * intro})`; ctx.fillText(line.text, line.x, line.baseline); drawDigits(offCtx, line, sprites.dark, seconds, 0.3, 0.5); offCtx.globalCompositeOperation = "destination-in"; setFont(offCtx, line); offCtx.fillStyle = "#000"; offCtx.fillText(line.text, line.x, line.baseline); ctx.globalAlpha = 0.25 + 0.75 * intro; ctx.drawImage(off, 0, 0, width, height); ctx.globalAlpha = 1; } } }; const loop = (now: number) => { if (disposed) return; if (now - lastFrame >= FRAME_MS) { lastFrame = now; if (noHover) { const seconds = now / 1000; pointer = { x: width * (0.5 + 0.46 * Math.sin(seconds * 0.55)), y: height * (0.5 + 0.4 * Math.sin(seconds * 0.9 + 1)), }; } paint(now); } raf = requestAnimationFrame(loop); }; const restart = () => { cancelAnimationFrame(raf); measure(); // Hidden at this breakpoint (display: none): nothing to draw. if (width === 0 || height === 0) return; if (reducedMotion) { paint(performance.now()); return; } raf = requestAnimationFrame(loop); }; const onPointerMove = (event: PointerEvent) => { const box = wrap.getBoundingClientRect(); pointer = { x: event.clientX - box.left, y: event.clientY - box.top }; }; const onPointerLeave = () => { pointer = null; }; const resizeObserver = new ResizeObserver(() => restart()); document.fonts.ready.then(() => { if (disposed) return; startedAt = performance.now(); restart(); setPainted(true); resizeObserver.observe(wrap); if (!reducedMotion && !noHover) { window.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerleave", onPointerLeave); } }); return () => { disposed = true; cancelAnimationFrame(raf); resizeObserver.disconnect(); window.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerleave", onPointerLeave); }; }, [lines, INK, PAPER, invert]); return ( {lines.map((line) => ( {line.text} ))} ); };