From 73b95a9ae92413910b925ce21aaed9568cf89d72 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 3 Jul 2025 19:49:40 +0200 Subject: [PATCH] optimize LanguageMorpher performance for mobile devices --- src/components/Morpher.tsx | 106 ++++++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/src/components/Morpher.tsx b/src/components/Morpher.tsx index 0ad27ed4211..b0d302e0596 100644 --- a/src/components/Morpher.tsx +++ b/src/components/Morpher.tsx @@ -1,6 +1,8 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" + +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion" type MorpherProps = { words: string[] @@ -11,16 +13,37 @@ const Morpher = ({ words, charSet = "abcdefghijklmnopqrstuvwxyz", }: MorpherProps) => { - const [state, setState] = useState({ text: words[0], words }) + const [currentText, setCurrentText] = useState(words[0]) + const [isAnimating, setIsAnimating] = useState(false) + const { prefersReducedMotion } = usePrefersReducedMotion() + + const morphTimeoutRef = useRef(null) + const morphIntervalRef = useRef(null) + const counterRef = useRef(0) + const wordsRef = useRef(words) + const currentTextRef = useRef(currentText) + const isAnimatingRef = useRef(false) + + useEffect(() => { + wordsRef.current = words + currentTextRef.current = currentText + isAnimatingRef.current = isAnimating + }, [words, currentText, isAnimating]) // loops over chars to morph a text to another const morpher = (start: string, end: string): void => { + // prevent multiple simultaneous animations + if (isAnimatingRef.current) return + + setIsAnimating(true) + isAnimatingRef.current = true + // array of chars to randomly morph the text between start and end const chars = charSet.split("") // duration of the global morph const duration = 3 // speed of the morph for each letter - const frameRate = 30 + const frameRate = 24 // text variables const textString = start.split("") @@ -37,6 +60,9 @@ const Morpher = ({ const splitTime = (duration * 70) / Math.max(slen, rlen) function update() { + // check if component is still mounted and animation should continue + if (!isAnimatingRef.current) return + // Update present date and spent time present = new Date() spentTime += present.getTime() - past @@ -60,8 +86,12 @@ const Morpher = ({ spentTime = 0 } - // Update DOM - setState({ ...state, text: textString.join("") }) + // Update text + const newText = textString.join("") + if (newText !== currentTextRef.current) { + setCurrentText(newText) + currentTextRef.current = newText + } // Save present date past = present.getTime() @@ -69,10 +99,13 @@ const Morpher = ({ // Loop if (count < Math.max(slen, rlen)) { // Only use a setTimeout if the frameRate is lower than 60FPS - // Remove the setTimeout if the frameRate is equal to 60FPS - morphTimeout = setTimeout(() => { + morphTimeoutRef.current = setTimeout(() => { window.requestAnimationFrame(update) }, 1000 / frameRate) + } else { + // Animation complete + setIsAnimating(false) + isAnimatingRef.current = false } } @@ -80,32 +113,53 @@ const Morpher = ({ update() } - let morphTimeout: NodeJS.Timeout - useEffect(() => { - let counter = 0 - - const morphInterval = setInterval(() => { - const start = state.text - const end = state.words[counter] - - morpher(start, end) - - if (counter < state.words.length - 1) { - counter++ - } else { - counter = 0 + // If reduced motion is preferred, show static text cycling + if (prefersReducedMotion) { + morphIntervalRef.current = setInterval(() => { + counterRef.current = (counterRef.current + 1) % wordsRef.current.length + const nextWord = wordsRef.current[counterRef.current] + setCurrentText(nextWord) + currentTextRef.current = nextWord + }, 3000) + } else { + // Defer animation start by 2 seconds to improve initial page load + const startupDelay = setTimeout(() => { + morphIntervalRef.current = setInterval(() => { + // Don't start new animation if one is already running + if (isAnimatingRef.current) return + + const start = currentTextRef.current + const end = wordsRef.current[counterRef.current] + + morpher(start, end) + + counterRef.current = + (counterRef.current + 1) % wordsRef.current.length + }, 3000) + }, 2000) + + return () => { + clearTimeout(startupDelay) } - }, 3000) + } return () => { - clearInterval(morphInterval) - clearTimeout(morphTimeout) + if (morphIntervalRef.current) { + clearInterval(morphIntervalRef.current) + morphIntervalRef.current = null + } + if (morphTimeoutRef.current) { + clearTimeout(morphTimeoutRef.current) + morphTimeoutRef.current = null + } + setIsAnimating(false) + isAnimatingRef.current = false } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [prefersReducedMotion, charSet]) - return state.text + return currentText } export default Morpher