Files
mintel.me/apps/web/src/components/bits/BitReveal.tsx
T
mmintelandClaude Opus 5.5 dc4a01cbe4 feat: binary micro-interactions across the page
Section headlines are written out behind a column of digits, mono labels
resolve from binary, the portrait assembles itself from bits, section indexes
rattle and keep one bit alive, cards get corner marks, the header shows the
scroll position as an eight-bit number, pressed links and buttons shed a few
digits, and the work stage leans towards the pointer. All effects respect
reduced motion and leave the content readable without JavaScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:14:22 +02:00

90 lines
3.1 KiB
TypeScript

"use client";
import * as React from "react";
import { useInViewOnce } from "./useInViewOnce";
const CELL = 16;
const DURATION_MS = 1700;
/**
* BitReveal: covers whatever lies beneath it with a field of binary digits that
* dissolves cell by cell once it scrolls into view, so the picture underneath
* appears to assemble itself out of bits. Without JavaScript nothing is covered.
*/
export const BitReveal: React.FC<{
/** Colour of the covering cells; should match the surface behind the picture. */
surface?: string;
}> = ({ surface = "#0f172a" }) => {
const { ref, seen, still } = useInViewOnce<HTMLCanvasElement>(0.25);
const [done, setDone] = React.useState(false);
// Cover the picture as soon as the script runs, before it is scrolled into view.
React.useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setDone(true);
return;
}
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = surface;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}, [ref, surface]);
React.useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!seen || still || !canvas || !ctx) return;
const width = (canvas.width = canvas.offsetWidth);
const height = (canvas.height = canvas.offsetHeight);
const cols = Math.ceil(width / CELL);
const rows = Math.ceil(height / CELL);
// Each cell disappears at its own moment; cells further right go a little later.
const when = new Float32Array(cols * rows);
const bit = new Uint8Array(cols * rows);
for (let i = 0; i < when.length; i++) {
const col = i % cols;
when[i] = Math.random() * 0.7 + (col / cols) * 0.3;
bit[i] = Math.random() < 0.5 ? 0 : 1;
}
ctx.font = `11px ui-monospace, Menlo, monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let raf = 0;
const start = performance.now();
const frame = (now: number) => {
const progress = Math.min(1, (now - start) / DURATION_MS);
ctx.clearRect(0, 0, width, height);
for (let i = 0; i < when.length; i++) {
const left = (when[i] ?? 0) - progress;
if (left <= 0) continue;
const x = (i % cols) * CELL;
const y = Math.floor(i / cols) * CELL;
ctx.fillStyle = surface;
ctx.fillRect(x, y, CELL, CELL);
// Cells about to vanish light up first.
ctx.fillStyle = left < 0.08 ? "#f8fafc" : "#475569";
if (Math.random() < 0.02) bit[i] = bit[i] === 1 ? 0 : 1;
ctx.fillText(String(bit[i] ?? 0), x + CELL / 2, y + CELL / 2);
}
if (progress < 1) raf = requestAnimationFrame(frame);
else setDone(true);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [ref, seen, still, surface]);
if (done) return null;
return (
<canvas
ref={ref}
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
/>
);
};