1
1
"use client"
2
2
3
- import { useEffect , useState } from "react"
3
+ import { useEffect , useRef , useState } from "react"
4
+
5
+ import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"
4
6
5
7
type MorpherProps = {
6
8
words : string [ ]
@@ -11,16 +13,37 @@ const Morpher = ({
11
13
words,
12
14
charSet = "abcdefghijklmnopqrstuvwxyz" ,
13
15
} : MorpherProps ) => {
14
- const [ state , setState ] = useState ( { text : words [ 0 ] , words } )
16
+ const [ currentText , setCurrentText ] = useState ( words [ 0 ] )
17
+ const [ isAnimating , setIsAnimating ] = useState ( false )
18
+ const { prefersReducedMotion } = usePrefersReducedMotion ( )
19
+
20
+ const morphTimeoutRef = useRef < NodeJS . Timeout | null > ( null )
21
+ const morphIntervalRef = useRef < NodeJS . Timeout | null > ( null )
22
+ const counterRef = useRef ( 0 )
23
+ const wordsRef = useRef ( words )
24
+ const currentTextRef = useRef ( currentText )
25
+ const isAnimatingRef = useRef ( false )
26
+
27
+ useEffect ( ( ) => {
28
+ wordsRef . current = words
29
+ currentTextRef . current = currentText
30
+ isAnimatingRef . current = isAnimating
31
+ } , [ words , currentText , isAnimating ] )
15
32
16
33
// loops over chars to morph a text to another
17
34
const morpher = ( start : string , end : string ) : void => {
35
+ // prevent multiple simultaneous animations
36
+ if ( isAnimatingRef . current ) return
37
+
38
+ setIsAnimating ( true )
39
+ isAnimatingRef . current = true
40
+
18
41
// array of chars to randomly morph the text between start and end
19
42
const chars = charSet . split ( "" )
20
43
// duration of the global morph
21
44
const duration = 3
22
45
// speed of the morph for each letter
23
- const frameRate = 30
46
+ const frameRate = 24
24
47
25
48
// text variables
26
49
const textString = start . split ( "" )
@@ -37,6 +60,9 @@ const Morpher = ({
37
60
const splitTime = ( duration * 70 ) / Math . max ( slen , rlen )
38
61
39
62
function update ( ) {
63
+ // check if component is still mounted and animation should continue
64
+ if ( ! isAnimatingRef . current ) return
65
+
40
66
// Update present date and spent time
41
67
present = new Date ( )
42
68
spentTime += present . getTime ( ) - past
@@ -60,52 +86,80 @@ const Morpher = ({
60
86
spentTime = 0
61
87
}
62
88
63
- // Update DOM
64
- setState ( { ...state , text : textString . join ( "" ) } )
89
+ // Update text
90
+ const newText = textString . join ( "" )
91
+ if ( newText !== currentTextRef . current ) {
92
+ setCurrentText ( newText )
93
+ currentTextRef . current = newText
94
+ }
65
95
66
96
// Save present date
67
97
past = present . getTime ( )
68
98
69
99
// Loop
70
100
if ( count < Math . max ( slen , rlen ) ) {
71
101
// Only use a setTimeout if the frameRate is lower than 60FPS
72
- // Remove the setTimeout if the frameRate is equal to 60FPS
73
- morphTimeout = setTimeout ( ( ) => {
102
+ morphTimeoutRef . current = setTimeout ( ( ) => {
74
103
window . requestAnimationFrame ( update )
75
104
} , 1000 / frameRate )
105
+ } else {
106
+ // Animation complete
107
+ setIsAnimating ( false )
108
+ isAnimatingRef . current = false
76
109
}
77
110
}
78
111
79
112
// Start loop
80
113
update ( )
81
114
}
82
115
83
- let morphTimeout : NodeJS . Timeout
84
-
85
116
useEffect ( ( ) => {
86
- let counter = 0
87
-
88
- const morphInterval = setInterval ( ( ) => {
89
- const start = state . text
90
- const end = state . words [ counter ]
91
-
92
- morpher ( start , end )
93
-
94
- if ( counter < state . words . length - 1 ) {
95
- counter ++
96
- } else {
97
- counter = 0
117
+ // If reduced motion is preferred, show static text cycling
118
+ if ( prefersReducedMotion ) {
119
+ morphIntervalRef . current = setInterval ( ( ) => {
120
+ counterRef . current = ( counterRef . current + 1 ) % wordsRef . current . length
121
+ const nextWord = wordsRef . current [ counterRef . current ]
122
+ setCurrentText ( nextWord )
123
+ currentTextRef . current = nextWord
124
+ } , 3000 )
125
+ } else {
126
+ // Defer animation start by 2 seconds to improve initial page load
127
+ const startupDelay = setTimeout ( ( ) => {
128
+ morphIntervalRef . current = setInterval ( ( ) => {
129
+ // Don't start new animation if one is already running
130
+ if ( isAnimatingRef . current ) return
131
+
132
+ const start = currentTextRef . current
133
+ const end = wordsRef . current [ counterRef . current ]
134
+
135
+ morpher ( start , end )
136
+
137
+ counterRef . current =
138
+ ( counterRef . current + 1 ) % wordsRef . current . length
139
+ } , 3000 )
140
+ } , 2000 )
141
+
142
+ return ( ) => {
143
+ clearTimeout ( startupDelay )
98
144
}
99
- } , 3000 )
145
+ }
100
146
101
147
return ( ) => {
102
- clearInterval ( morphInterval )
103
- clearTimeout ( morphTimeout )
148
+ if ( morphIntervalRef . current ) {
149
+ clearInterval ( morphIntervalRef . current )
150
+ morphIntervalRef . current = null
151
+ }
152
+ if ( morphTimeoutRef . current ) {
153
+ clearTimeout ( morphTimeoutRef . current )
154
+ morphTimeoutRef . current = null
155
+ }
156
+ setIsAnimating ( false )
157
+ isAnimatingRef . current = false
104
158
}
105
159
// eslint-disable-next-line react-hooks/exhaustive-deps
106
- } , [ ] )
160
+ } , [ prefersReducedMotion , charSet ] )
107
161
108
- return state . text
162
+ return currentText
109
163
}
110
164
111
165
export default Morpher
0 commit comments