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>
This commit is contained in:
2026-10-04 00:14:22 +02:00
co-authored by Claude Opus 5.5
parent 123ab8e354
commit dc4a01cbe4
17 changed files with 556 additions and 33 deletions
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { useInViewOnce } from "./useInViewOnce";
const toBits = (value: number, width: number): string =>
value.toString(2).padStart(width, "0").slice(-width);
/**
* BinaryTicker: a number shown in binary. The bits rattle when it scrolls into
* view and then settle on the value; with `alive` a single bit keeps flipping.
*/
export const BinaryTicker: React.FC<{
value: number;
width?: number;
className?: string;
alive?: boolean;
}> = ({ value, width = 8, className = "", alive = false }) => {
const settled = toBits(value, width);
const { ref, seen, still } = useInViewOnce<HTMLSpanElement>(0.6);
const [shown, setShown] = React.useState(settled);
React.useEffect(() => {
if (!seen || still) return;
let tick = 0;
const timer = window.setInterval(() => {
tick += 1;
if (tick > 14) {
window.clearInterval(timer);
setShown(settled);
return;
}
setShown(
Array.from(settled)
.map((bit, i) =>
i < tick - 6 ? bit : Math.random() < 0.5 ? "0" : "1",
)
.join(""),
);
}, 45);
return () => window.clearInterval(timer);
}, [seen, still, settled]);
React.useEffect(() => {
if (!alive || !seen || still) return;
const timer = window.setInterval(() => {
const index = Math.floor(Math.random() * width);
setShown((current) =>
Array.from(current)
.map((bit, i) => (i === index ? (bit === "0" ? "1" : "0") : bit))
.join(""),
);
window.setTimeout(() => setShown(settled), 260);
}, 2400);
return () => window.clearInterval(timer);
}, [alive, seen, still, width, settled]);
return (
<span ref={ref} className={className} aria-hidden="true">
{shown}
</span>
);
};
+44
View File
@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
const COUNT = 10;
const LIFE_MS = 620;
/**
* BitBurst: a handful of binary digits fly out from the point where a link or
* button is pressed. Purely decorative, and off when reduced motion is requested.
*/
export const BitBurst: React.FC = () => {
React.useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target?.closest("a, button")) return;
const layer = document.createElement("div");
layer.setAttribute("aria-hidden", "true");
layer.className = "bit-burst";
layer.style.left = `${event.clientX}px`;
layer.style.top = `${event.clientY}px`;
for (let i = 0; i < COUNT; i++) {
const angle = (i / COUNT) * Math.PI * 2 + Math.random() * 0.5;
const distance = 26 + Math.random() * 34;
const digit = document.createElement("span");
digit.textContent = Math.random() < 0.5 ? "0" : "1";
digit.style.setProperty("--dx", `${Math.cos(angle) * distance}px`);
digit.style.setProperty("--dy", `${Math.sin(angle) * distance}px`);
digit.style.animationDelay = `${Math.random() * 60}ms`;
layer.appendChild(digit);
}
document.body.appendChild(layer);
window.setTimeout(() => layer.remove(), LIFE_MS + 120);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
return null;
};
@@ -0,0 +1,89 @@
"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"
/>
);
};
+66
View File
@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { useInViewOnce } from "./useInViewOnce";
const DIGITS = "0110100110010110011010011001011001101001";
const DURATION = 0.95;
const EASE = [0.65, 0, 0.35, 1] as const;
/**
* BitWipe: reveals its content from left to right behind a narrow column of
* binary digits, as if the text were being written out bit by bit. The content
* keeps its place the whole time, so nothing reflows.
*
* Visibility is observed on the outer wrapper: the inner element is clipped away
* completely at first, and a fully clipped element never counts as "in view".
*/
export const BitWipe: React.FC<{
children: React.ReactNode;
className?: string;
delay?: number;
/** Colour of the digit column: dark on light surfaces, light on dark ones. */
tone?: "dark" | "light";
}> = ({ children, className = "", delay = 0, tone = "dark" }) => {
const { ref, seen, still } = useInViewOnce<HTMLDivElement>(0.35);
const open = seen || still;
return (
<div ref={ref} className={`relative ${className}`}>
<motion.div
initial={false}
animate={{
clipPath: open
? "inset(-12% -2% -12% -2%)"
: "inset(-12% 100% -12% -2%)",
}}
transition={
still ? { duration: 0 } : { duration: DURATION, delay, ease: EASE }
}
// Server render and no-JS: content is simply visible.
style={{ clipPath: "none" }}
>
{children}
</motion.div>
{seen && !still && (
<motion.span
aria-hidden="true"
className={`pointer-events-none absolute inset-y-0 w-[1.1em] overflow-hidden break-all font-mono text-[11px] leading-[1.05] tracking-normal [writing-mode:vertical-rl] md:text-[13px] ${
tone === "light" ? "text-white" : "text-slate-900"
}`}
initial={{ left: "0%", opacity: 0 }}
animate={{ left: ["0%", "100%"], opacity: [0, 1, 1, 0] }}
transition={{
duration: DURATION,
delay,
ease: EASE,
opacity: { duration: DURATION, delay, times: [0, 0.08, 0.86, 1] },
}}
>
{DIGITS}
</motion.span>
)}
</div>
);
};
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { useInViewOnce } from "./useInViewOnce";
const FRAME_MS = 32;
const scramble = (text: string, revealed: number): string =>
Array.from(text)
.map((char, index) => {
if (index < revealed || !/[A-Za-z0-9]/.test(char)) return char;
return Math.random() < 0.5 ? "0" : "1";
})
.join("");
/**
* DecodeText: text that resolves from binary digits, left to right. Meant for
* monospaced labels, where digits and letters have the same width and nothing shifts.
* Screen readers and no-JS visitors get the plain text.
*/
export const DecodeText: React.FC<{
text: string;
className?: string;
/** Run when scrolled into view, on hover, or both. */
trigger?: "view" | "hover" | "both";
}> = ({ text, className = "", trigger = "view" }) => {
const { ref, seen, still } = useInViewOnce<HTMLSpanElement>(0.6);
const [shown, setShown] = React.useState(text);
const timer = React.useRef(0);
const run = React.useCallback(() => {
if (still) return;
window.clearInterval(timer.current);
const length = Array.from(text).length;
const frames = Math.min(26, Math.max(12, length));
let frame = 0;
timer.current = window.setInterval(() => {
frame += 1;
if (frame >= frames) {
window.clearInterval(timer.current);
setShown(text);
return;
}
setShown(scramble(text, Math.floor((frame / frames) * length)));
}, FRAME_MS);
}, [still, text]);
React.useEffect(() => {
if (seen && trigger !== "hover") run();
return () => window.clearInterval(timer.current);
}, [seen, trigger, run]);
return (
<span
ref={ref}
className={className}
onPointerEnter={trigger === "view" ? undefined : run}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true">{shown}</span>
</span>
);
};
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
/** ScrollBits: how far the page is scrolled, as an eight-bit binary number. */
export const ScrollBits: React.FC<{ className?: string }> = ({
className = "",
}) => {
const [percent, setPercent] = React.useState(0);
React.useEffect(() => {
const update = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setPercent(max > 0 ? Math.round((window.scrollY / max) * 100) : 0);
};
update();
window.addEventListener("scroll", update, { passive: true });
return () => window.removeEventListener("scroll", update);
}, []);
return (
<span className={className} aria-hidden="true">
{percent.toString(2).padStart(8, "0")}
<span className="ml-3 text-slate-300">
{String(percent).padStart(3, "0")}%
</span>
</span>
);
};
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
/** True once the element has entered the viewport. Also reports reduced-motion preference. */
export const useInViewOnce = <T extends Element>(
threshold = 0.4,
): { ref: React.RefObject<T | null>; seen: boolean; still: boolean } => {
const ref = React.useRef<T | null>(null);
const [seen, setSeen] = React.useState(false);
const [still, setStill] = React.useState(false);
React.useEffect(() => {
setStill(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setSeen(true);
observer.disconnect();
}
},
{ threshold },
);
observer.observe(node);
return () => observer.disconnect();
}, [threshold]);
return { ref, seen, still };
};