Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 80 additions & 26 deletions src/components/Morpher.tsx
Original file line number Diff line number Diff line change
@@ -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[]
Expand All @@ -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<NodeJS.Timeout | null>(null)
const morphIntervalRef = useRef<NodeJS.Timeout | null>(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("")
Expand All @@ -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
Expand All @@ -60,52 +86,80 @@ 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()

// 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
}
}

// Start loop
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