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:
@@ -36,6 +36,16 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
|
||||
- The hero design (binary-digit headline) is approved: do not change its design.
|
||||
- Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind.
|
||||
|
||||
## Motion (binary micro-interactions)
|
||||
|
||||
- Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits),
|
||||
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
|
||||
`BinaryTicker`, `ScrollBits`, `BitBurst`. Reuse them instead of inventing new effects.
|
||||
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
|
||||
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
|
||||
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
|
||||
- Every effect must respect `prefers-reduced-motion` and leave content readable without JavaScript.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- Functional core, imperative shell. Pure logic goes in `apps/web/src/domain/` (no I/O, no clock, no
|
||||
|
||||
@@ -1004,3 +1004,87 @@ pre:has(code[class*="language-"]) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Binary micro-interactions ─────────────────────────────────────────────── */
|
||||
|
||||
/* Digits that fly out from a pressed link or button (see BitBurst). */
|
||||
.bit-burst {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
/* On the layer, not the digits: a fixed layer blends as a whole with the page. */
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
.bit-burst span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
opacity: 0;
|
||||
animation: bit-burst-fly 620ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
@keyframes bit-burst-fly {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy)))
|
||||
scale(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Corner marks that snap onto a card on hover or focus. */
|
||||
.bit-card {
|
||||
position: relative;
|
||||
}
|
||||
.bit-card::before,
|
||||
.bit-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid currentColor;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 250ms ease,
|
||||
transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.bit-card::before {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border-left: 0;
|
||||
border-bottom: 0;
|
||||
transform: translate(-6px, 6px);
|
||||
}
|
||||
.bit-card::after {
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
transform: translate(6px, -6px);
|
||||
}
|
||||
.bit-card:hover::before,
|
||||
.bit-card:hover::after,
|
||||
.bit-card:focus-visible::before,
|
||||
.bit-card:focus-visible::after,
|
||||
.bit-card[aria-selected="true"]::before,
|
||||
.bit-card[aria-selected="true"]::after {
|
||||
opacity: 0.55;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bit-burst {
|
||||
display: none;
|
||||
}
|
||||
.bit-card::before,
|
||||
.bit-card::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
||||
import { Analytics } from "@/src/components/Analytics";
|
||||
import { BitBurst } from "@/src/components/bits/BitBurst";
|
||||
import { Footer } from "@/src/components/Footer";
|
||||
import { Header } from "@/src/components/Header";
|
||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||
@@ -70,6 +71,7 @@ export default function RootLayout({
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<BitBurst />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { Faq } from "@/src/components/home/Faq";
|
||||
@@ -218,7 +219,7 @@ export default function LandingPage() {
|
||||
key={item}
|
||||
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
|
||||
>
|
||||
{item}
|
||||
<DecodeText text={item} trigger="both" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
|
||||
import { BinaryTicker } from "./bits/BinaryTicker";
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -56,11 +57,10 @@ export const Footer: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
{/* Subtle binary decoration */}
|
||||
<span
|
||||
className="text-[8px] text-slate-200 tracking-[0.5em] select-none font-mono"
|
||||
aria-hidden="true"
|
||||
>
|
||||
01001101 01001001 01001110
|
||||
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
|
||||
{[77, 73, 78].map((code) => (
|
||||
<BinaryTicker key={code} value={code} alive />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
||||
import { DecodeText } from "./bits/DecodeText";
|
||||
import { ScrollBits } from "./bits/ScrollBits";
|
||||
|
||||
/** Header: the logo and one action. Everything else lives on the homepage. */
|
||||
export const Header: React.FC = () => {
|
||||
@@ -52,6 +54,8 @@ export const Header: React.FC = () => {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
|
||||
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
@@ -59,7 +63,7 @@ export const Header: React.FC = () => {
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
Get in touch
|
||||
<DecodeText text="Get in touch" trigger="hover" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { BinaryTicker } from "@/src/components/bits/BinaryTicker";
|
||||
import { BitWipe } from "@/src/components/bits/BitWipe";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
|
||||
interface BlockProps {
|
||||
@@ -32,22 +35,22 @@ export const Block: React.FC<BlockProps> = ({
|
||||
{index}
|
||||
</span>
|
||||
<span className="h-px w-10 bg-slate-300" />
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
<DecodeText
|
||||
text={label}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500"
|
||||
/>
|
||||
<BinaryTicker
|
||||
value={parseInt(index, 10)}
|
||||
alive
|
||||
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{parseInt(index, 10).toString(2).padStart(8, "0")}
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1}>
|
||||
<BitWipe delay={0.1}>
|
||||
<h2 className="m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
|
||||
{title}
|
||||
</h2>
|
||||
</Reveal>
|
||||
</BitWipe>
|
||||
<div className="mt-14 md:mt-24">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { PathScene } from "@/src/components/illustrations/PathScenes";
|
||||
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
|
||||
|
||||
@@ -50,9 +51,11 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
||||
<div className="md:order-2 md:col-span-7">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300">
|
||||
{current?.title}
|
||||
</span>
|
||||
<DecodeText
|
||||
key={current?.title}
|
||||
text={current?.title ?? ""}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
|
||||
/>
|
||||
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
|
||||
{step + 1} / {path.steps.length}
|
||||
</span>
|
||||
@@ -151,7 +154,7 @@ export const PathPicker: React.FC = () => {
|
||||
aria-selected={selected}
|
||||
aria-controls={`path-panel-${path.id}`}
|
||||
onClick={() => setActiveId(path.id)}
|
||||
className={`group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
|
||||
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
|
||||
selected
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { BitReveal } from "@/src/components/bits/BitReveal";
|
||||
import { BitWipe } from "@/src/components/bits/BitWipe";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
|
||||
/**
|
||||
* PortraitSection: the introduction, with the portrait fading out of a dark surface
|
||||
@@ -22,8 +25,8 @@ export const PortraitSection: React.FC<{
|
||||
{/* Portrait: right side on wide screens, top on small ones. */}
|
||||
<motion.div
|
||||
className="relative h-[110vw] w-full md:absolute md:inset-y-0 md:right-0 md:h-auto md:w-[62%]"
|
||||
initial={{ opacity: 0, scale: 1.06 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
initial={{ scale: 1.06 }}
|
||||
whileInView={{ scale: 1 }}
|
||||
viewport={{ once: true, margin: "-10%" }}
|
||||
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
@@ -39,6 +42,8 @@ export const PortraitSection: React.FC<{
|
||||
<div className="absolute inset-0 hidden bg-gradient-to-r from-slate-900 via-slate-900/75 to-slate-900/10 md:block" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-slate-900 to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-slate-900 to-transparent" />
|
||||
{/* The portrait assembles itself out of binary digits. */}
|
||||
<BitReveal />
|
||||
</motion.div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-[1400px] px-5 pb-20 md:px-12 md:py-40">
|
||||
@@ -54,13 +59,16 @@ export const PortraitSection: React.FC<{
|
||||
{index}
|
||||
</span>
|
||||
<span className="h-px w-10 bg-slate-600" />
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400">
|
||||
{label}
|
||||
</span>
|
||||
<DecodeText
|
||||
text={label}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<BitWipe tone="light" delay={0.35}>
|
||||
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
|
||||
{title}
|
||||
</h2>
|
||||
</BitWipe>
|
||||
<div className="mt-10 md:mt-14">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useMotionValue, useSpring } from "framer-motion";
|
||||
|
||||
export type WorkItem = {
|
||||
readonly name: string;
|
||||
@@ -88,10 +88,26 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
|
||||
const active = inView && !still;
|
||||
|
||||
// The frames lean a few degrees towards the pointer.
|
||||
const tiltX = useSpring(useMotionValue(0), { stiffness: 120, damping: 18 });
|
||||
const tiltY = useSpring(useMotionValue(0), { stiffness: 120, damping: 18 });
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (still || event.pointerType !== "mouse") return;
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
tiltY.set(((event.clientX - box.left) / box.width - 0.5) * 7);
|
||||
tiltX.set(-((event.clientY - box.top) / box.height - 0.5) * 5);
|
||||
};
|
||||
const onPointerLeave = () => {
|
||||
tiltX.set(0);
|
||||
tiltY.set(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 md:px-12 md:pt-14"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerLeave={onPointerLeave}
|
||||
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-60"
|
||||
@@ -102,7 +118,14 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl">
|
||||
<motion.div
|
||||
className="relative mx-auto max-w-4xl"
|
||||
style={{
|
||||
rotateX: tiltX,
|
||||
rotateY: tiltY,
|
||||
transformStyle: "preserve-3d",
|
||||
}}
|
||||
>
|
||||
{/* Desktop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
@@ -150,7 +173,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user