On a phone the headline was built from 4px cells and the background field kept its desktop ink, so the digits were specks or not visible at all. The headline now uses cells of at least 6px there, and the background digits are larger and stronger below 640px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
"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<HTMLHeadingElement>(null);
|
|
const INK = invert ? LIGHT : DARK;
|
|
const PAPER = invert ? DARK : LIGHT;
|
|
const canvasRef = React.useRef<HTMLCanvasElement>(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<HTMLSpanElement>("[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 (
|
|
<Tag ref={wrapRef} className={`relative m-0 ${className}`}>
|
|
{lines.map((line) => (
|
|
<span
|
|
key={line.text}
|
|
data-bit-line
|
|
className={`block w-fit whitespace-nowrap transition-colors duration-300 ${
|
|
painted
|
|
? "text-transparent"
|
|
: line.tone === "solid"
|
|
? invert
|
|
? "text-white"
|
|
: "text-slate-900"
|
|
: invert
|
|
? "text-slate-600"
|
|
: "text-slate-300"
|
|
}`}
|
|
>
|
|
{line.text}
|
|
</span>
|
|
))}
|
|
<canvas
|
|
ref={canvasRef}
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
|
/>
|
|
</Tag>
|
|
);
|
|
};
|