Skip to content

Commit f5470a2

Browse files
ui: make section headings follow global mouse movement
1 parent 291f905 commit f5470a2

1 file changed

Lines changed: 85 additions & 39 deletions

File tree

src/components/SectionHeading.tsx

Lines changed: 85 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

3-
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
3+
import type { CSSProperties, ReactNode } from "react";
4+
import { useEffect, useRef } from "react";
45
import { Reveal } from "./Reveal";
56

67
const toneBySection: Record<string, string> = {
@@ -14,7 +15,8 @@ const toneBySection: Record<string, string> = {
1415
Contact: "rose",
1516
};
1617

17-
function resetInteractiveHeading(target: HTMLDivElement) {
18+
function resetInteractiveHeading(target: HTMLDivElement | null) {
19+
if (!target) return;
1820
const word = target.querySelector<HTMLElement>(".section-dot-word-interactive");
1921
if (word) word.style.transform = "";
2022
target.querySelectorAll<SVGElement>(".section-letter-svg").forEach((letter) => {
@@ -39,46 +41,90 @@ export function SectionHeading({
3941
const displayWord = eyebrow.toUpperCase();
4042
const safeId = displayWord.toLowerCase().replace(/[^a-z0-9]+/g, "-");
4143
const renderedWidth = Math.min(1120, Math.max(460, displayWord.length * 88));
42-
43-
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
44-
if (event.pointerType !== "mouse") return;
45-
if (document.documentElement.classList.contains("motion-off")) return;
46-
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
47-
48-
const target = event.currentTarget;
49-
const rect = target.getBoundingClientRect();
50-
const x = Math.max(-1, Math.min(1, ((event.clientX - rect.left) / rect.width - 0.5) * 2));
51-
const y = Math.max(-1, Math.min(1, ((event.clientY - rect.top) / rect.height - 0.5) * 2));
52-
const word = target.querySelector<HTMLElement>(".section-dot-word-interactive");
53-
const letters = Array.from(target.querySelectorAll<SVGElement>(".section-letter-svg"));
54-
55-
if (word) {
56-
word.style.transform = `translate3d(${(x * 15).toFixed(2)}px, ${(y * 10).toFixed(2)}px, 0) rotateX(${(-y * 3.2).toFixed(2)}deg) rotateY(${(x * 4.4).toFixed(2)}deg)`;
57-
}
58-
59-
const middle = (letters.length - 1) / 2;
60-
letters.forEach((letter, index) => {
61-
const spread = index - middle;
62-
const depth = 4.5 + (index % 4) * 2.1;
63-
const localX = x * depth + y * spread * 0.42;
64-
const localY = y * depth * 0.68 + x * Math.sin(index * 1.35) * 1.7;
65-
const rotate = x * spread * 0.34;
66-
letter.style.transform = `translate3d(${localX.toFixed(2)}px, ${localY.toFixed(2)}px, ${depth.toFixed(1)}px) rotateZ(${rotate.toFixed(2)}deg)`;
67-
});
68-
};
69-
70-
const handlePointerLeave = (event: ReactPointerEvent<HTMLDivElement>) => {
71-
resetInteractiveHeading(event.currentTarget);
72-
};
44+
const bannerRef = useRef<HTMLDivElement>(null);
45+
46+
useEffect(() => {
47+
let frame = 0;
48+
let pointerX = window.innerWidth / 2;
49+
let pointerY = window.innerHeight / 2;
50+
51+
const update = () => {
52+
frame = 0;
53+
const target = bannerRef.current;
54+
if (!target) return;
55+
if (document.documentElement.classList.contains("motion-off")) {
56+
resetInteractiveHeading(target);
57+
return;
58+
}
59+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
60+
resetInteractiveHeading(target);
61+
return;
62+
}
63+
64+
const rect = target.getBoundingClientRect();
65+
const centerX = rect.left + rect.width / 2;
66+
const centerY = rect.top + rect.height / 2;
67+
const dx = pointerX - centerX;
68+
const dy = pointerY - centerY;
69+
const maxX = Math.max(window.innerWidth * 0.5, 1);
70+
const maxY = Math.max(window.innerHeight * 0.5, 1);
71+
const x = Math.max(-1, Math.min(1, dx / maxX));
72+
const y = Math.max(-1, Math.min(1, dy / maxY));
73+
74+
const viewportInfluence = Math.max(
75+
0.3,
76+
Math.min(1, 1 - Math.abs(centerY - window.innerHeight / 2) / (window.innerHeight * 0.9)),
77+
);
78+
79+
const word = target.querySelector<HTMLElement>(".section-dot-word-interactive");
80+
const letters = Array.from(target.querySelectorAll<SVGElement>(".section-letter-svg"));
81+
82+
if (word) {
83+
word.style.transform = `translate3d(${(x * 34 * viewportInfluence).toFixed(2)}px, ${(y * 22 * viewportInfluence).toFixed(2)}px, 0) rotateX(${(-y * 7 * viewportInfluence).toFixed(2)}deg) rotateY(${(x * 9 * viewportInfluence).toFixed(2)}deg)`;
84+
}
85+
86+
const middle = (letters.length - 1) / 2;
87+
letters.forEach((letter, index) => {
88+
const spread = index - middle;
89+
const depth = 8 + (index % 5) * 3.4;
90+
const localX = (x * depth + y * spread * 0.95) * viewportInfluence;
91+
const localY = (y * depth * 0.82 + x * Math.sin(index * 1.22) * 4.6) * viewportInfluence;
92+
const rotate = x * spread * 0.9 * viewportInfluence;
93+
letter.style.transform = `translate3d(${localX.toFixed(2)}px, ${localY.toFixed(2)}px, ${(depth * viewportInfluence).toFixed(1)}px) rotateZ(${rotate.toFixed(2)}deg)`;
94+
});
95+
};
96+
97+
const queueUpdate = () => {
98+
if (!frame) frame = window.requestAnimationFrame(update);
99+
};
100+
101+
const onPointerMove = (event: PointerEvent) => {
102+
if (event.pointerType && event.pointerType !== "mouse") return;
103+
pointerX = event.clientX;
104+
pointerY = event.clientY;
105+
queueUpdate();
106+
};
107+
108+
const onScroll = () => queueUpdate();
109+
const onResize = () => queueUpdate();
110+
111+
window.addEventListener("pointermove", onPointerMove, { passive: true });
112+
window.addEventListener("scroll", onScroll, { passive: true });
113+
window.addEventListener("resize", onResize, { passive: true });
114+
queueUpdate();
115+
116+
return () => {
117+
window.removeEventListener("pointermove", onPointerMove);
118+
window.removeEventListener("scroll", onScroll);
119+
window.removeEventListener("resize", onResize);
120+
if (frame) window.cancelAnimationFrame(frame);
121+
resetInteractiveHeading(bannerRef.current);
122+
};
123+
}, []);
73124

74125
return (
75126
<Reveal className="section-heading-v5" data-tone={tone}>
76-
<div
77-
className="section-dot-banner section-dot-banner-interactive"
78-
aria-hidden="true"
79-
onPointerMove={handlePointerMove}
80-
onPointerLeave={handlePointerLeave}
81-
>
127+
<div ref={bannerRef} className="section-dot-banner section-dot-banner-interactive" aria-hidden="true">
82128
<div
83129
className="section-dot-word-interactive"
84130
style={{ maxWidth: `${renderedWidth}px`, "--letter-count": displayWord.length } as CSSProperties}

0 commit comments

Comments
 (0)