@@ -6,60 +6,119 @@ import { animated, useSpring } from "@react-spring/web";
66interface ParallaxProps {
77 children : ReactNode ;
88 className ?: string ;
9- maxOffset ?: string ;
9+ maxOffset ?: string ; // e.g. "8rem"
10+ speed ?: number ; // e.g. 0.2
11+ thresholdPx ?: number ; // change threshold to avoid tiny updates (default 0.5px)
1012}
1113
12- export default function Parallax ( { children, className = "" , maxOffset = "8rem" } : ParallaxProps ) {
13- const ref = useRef < HTMLDivElement > ( null ) ;
14+ export default function Parallax ( {
15+ children,
16+ className = "" ,
17+ maxOffset = "8rem" ,
18+ speed = 0.2 ,
19+ thresholdPx = 0.5 ,
20+ } : ParallaxProps ) {
21+ const hostRef = useRef < HTMLDivElement > ( null ) ;
22+
1423 const [ springs , api ] = useSpring ( ( ) => ( {
1524 y : 0 ,
16- config : {
17- mass : 1 ,
18- tension : 280 ,
19- friction : 120 ,
20- } ,
25+ config : { mass : 1 , tension : 280 , friction : 120 } ,
2126 } ) ) ;
2227
2328 useEffect ( ( ) => {
24- // Reason: Convert CSS units to pixels
25- const convertToPixels = ( value : string ) => {
26- const temp = document . createElement ( "div" ) ;
27- temp . style . position = "absolute" ;
28- temp . style . height = value ;
29- document . body . appendChild ( temp ) ;
30- const pixels = temp . offsetHeight ;
31- document . body . removeChild ( temp ) ;
32- return pixels ;
29+ const el = hostRef . current ;
30+ if ( ! el ) return ;
31+
32+ // ---- convert CSS unit -> px (once) ----
33+ const toPx = ( value : string ) => {
34+ // single detached element reused for measurement
35+ const probe = document . createElement ( "div" ) ;
36+ probe . style . position = "absolute" ;
37+ probe . style . visibility = "hidden" ;
38+ probe . style . height = value ;
39+ document . body . appendChild ( probe ) ;
40+ const px = probe . offsetHeight ;
41+ probe . remove ( ) ;
42+ return px ;
3343 } ;
44+ const maxOffsetPx = toPx ( maxOffset ) ;
3445
35- const maxOffsetPx = convertToPixels ( maxOffset ) ;
46+ // ---- state we keep outside React render ----
47+ let rafId : number | null = null ;
48+ let latestScrollY = window . scrollY ;
49+ let lastApplied = - 1 ; // last y we sent to spring
50+ let pageVisible = ! document . hidden ;
51+ let inViewport = true ; // will be refined by IO below
3652
37- const handleScroll = ( ) => {
38- if ( ! ref . current ) return ;
53+ // Only animate when visible + on screen
54+ const shouldRun = ( ) => pageVisible && inViewport ;
3955
40- const scrollY = window . scrollY ;
56+ // Coalesced update (max once per frame)
57+ const update = ( ) => {
58+ rafId = null ;
59+ if ( ! shouldRun ( ) ) return ;
4160
42- // Reason: Simple parallax based only on scroll position
43- const parallaxSpeed = 0.2 ;
44- const offset = Math . min ( scrollY * parallaxSpeed , maxOffsetPx ) ;
61+ const offset = Math . min ( latestScrollY * speed , maxOffsetPx ) ;
62+ if ( Math . abs ( offset - lastApplied ) >= thresholdPx ) {
63+ lastApplied = offset ;
64+ // fire only if value changed enough
65+ api . start ( { y : offset } ) ;
66+ }
67+ } ;
68+
69+ const queueUpdate = ( ) => {
70+ if ( rafId == null ) {
71+ rafId = requestAnimationFrame ( update ) ;
72+ }
73+ } ;
4574
46- api . start ( { y : offset } ) ;
75+ const onScroll = ( ) => {
76+ latestScrollY = window . scrollY ;
77+ queueUpdate ( ) ;
4778 } ;
4879
49- window . addEventListener ( "scroll" , handleScroll , { passive : true } ) ;
50- window . addEventListener ( "resize" , handleScroll , { passive : true } ) ;
80+ // Pause/resume when tab visibility changes
81+ const onVisibility = ( ) => {
82+ pageVisible = ! document . hidden ;
83+ if ( pageVisible ) queueUpdate ( ) ;
84+ } ;
85+
86+ // Observe element visibility (viewport)
87+ const io = new IntersectionObserver (
88+ ( entries ) => {
89+ const entry = entries [ 0 ] ;
90+ inViewport = ! ! entry && ( entry . isIntersecting || entry . intersectionRatio > 0 ) ;
91+ if ( inViewport ) queueUpdate ( ) ;
92+ } ,
93+ { root : null , rootMargin : "0px" , threshold : [ 0 , 0.01 , 0.1 , 1 ] } ,
94+ ) ;
95+ io . observe ( el ) ;
96+
97+ // Recompute when element size/layout changes (less noisy than window resize)
98+ const ro = new ResizeObserver ( ( ) => {
99+ // layout shifts can change the perceived parallax; just recompute
100+ queueUpdate ( ) ;
101+ } ) ;
102+ ro . observe ( el ) ;
103+
104+ // Initial run
105+ onScroll ( ) ;
51106
52- // Reason: Initial calculation on mount
53- handleScroll ( ) ;
107+ // passive listener for scroll
108+ window . addEventListener ( "scroll" , onScroll , { passive : true } ) ;
109+ document . addEventListener ( "visibilitychange" , onVisibility ) ;
54110
55111 return ( ) => {
56- window . removeEventListener ( "scroll" , handleScroll ) ;
57- window . removeEventListener ( "resize" , handleScroll ) ;
112+ window . removeEventListener ( "scroll" , onScroll ) ;
113+ document . removeEventListener ( "visibilitychange" , onVisibility ) ;
114+ io . disconnect ( ) ;
115+ ro . disconnect ( ) ;
116+ if ( rafId != null ) cancelAnimationFrame ( rafId ) ;
58117 } ;
59- } , [ api , maxOffset ] ) ;
118+ } , [ api , maxOffset , speed , thresholdPx ] ) ;
60119
61120 return (
62- < div ref = { ref } className = { `relative ${ className } ` } >
121+ < div ref = { hostRef } className = { `relative ${ className } ` } >
63122 < animated . div
64123 style = { {
65124 transform : springs . y . to ( ( y ) => `translate3d(0, ${ y } px, 0)` ) ,
0 commit comments