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>
67 lines
2.2 KiB
TypeScript
67 lines
2.2 KiB
TypeScript
"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>
|
|
);
|
|
};
|