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" ;
45import { Reveal } from "./Reveal" ;
56
67const 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 - z 0 - 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