Skip to content

Commit 73b95a9

Browse files
committed
optimize LanguageMorpher performance for mobile devices
1 parent 42e6555 commit 73b95a9

File tree

1 file changed

+80
-26
lines changed

1 file changed

+80
-26
lines changed

src/components/Morpher.tsx

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client"
22

3-
import { useEffect, useState } from "react"
3+
import { useEffect, useRef, useState } from "react"
4+
5+
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"
46

57
type MorpherProps = {
68
words: string[]
@@ -11,16 +13,37 @@ const Morpher = ({
1113
words,
1214
charSet = "abcdefghijklmnopqrstuvwxyz",
1315
}: 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])
1532

1633
// loops over chars to morph a text to another
1734
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+
1841
// array of chars to randomly morph the text between start and end
1942
const chars = charSet.split("")
2043
// duration of the global morph
2144
const duration = 3
2245
// speed of the morph for each letter
23-
const frameRate = 30
46+
const frameRate = 24
2447

2548
// text variables
2649
const textString = start.split("")
@@ -37,6 +60,9 @@ const Morpher = ({
3760
const splitTime = (duration * 70) / Math.max(slen, rlen)
3861

3962
function update() {
63+
// check if component is still mounted and animation should continue
64+
if (!isAnimatingRef.current) return
65+
4066
// Update present date and spent time
4167
present = new Date()
4268
spentTime += present.getTime() - past
@@ -60,52 +86,80 @@ const Morpher = ({
6086
spentTime = 0
6187
}
6288

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+
}
6595

6696
// Save present date
6797
past = present.getTime()
6898

6999
// Loop
70100
if (count < Math.max(slen, rlen)) {
71101
// 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(() => {
74103
window.requestAnimationFrame(update)
75104
}, 1000 / frameRate)
105+
} else {
106+
// Animation complete
107+
setIsAnimating(false)
108+
isAnimatingRef.current = false
76109
}
77110
}
78111

79112
// Start loop
80113
update()
81114
}
82115

83-
let morphTimeout: NodeJS.Timeout
84-
85116
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)
98144
}
99-
}, 3000)
145+
}
100146

101147
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
104158
}
105159
// eslint-disable-next-line react-hooks/exhaustive-deps
106-
}, [])
160+
}, [prefersReducedMotion, charSet])
107161

108-
return state.text
162+
return currentText
109163
}
110164

111165
export default Morpher

0 commit comments

Comments
 (0)