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>
64 lines
1.8 KiB
TypeScript
64 lines
1.8 KiB
TypeScript
"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>
|
|
);
|
|
};
|