UNPKG

@erosnicolau/animated-text

Version:

Advanced React animated text component with comprehensive animation effects

192 lines (191 loc) 9.04 kB
import { useEffect, useRef, useState } from 'react'; import { DEFAULT_TIMING, getPhraseDelay } from '../types'; import { useIntersectionObserver } from './useIntersectionObserver'; import { ANIMATION_DURATION } from '../constants/animations'; // Animation timing constants const INDIVIDUAL_REPLAY_START_DELAY = ANIMATION_DURATION.FAST; // ms - reduced delay for individual component replays export const useAnimationState = ({ animation = 'block', startDelay = DEFAULT_TIMING.startDelay, itemSpacing = DEFAULT_TIMING.itemSpacing, itemCount = 0, phraseStyles, forceStart = false, replayKey = 0, isIndividualReplay = false, customTimingFunction, onAnimationStart, onAnimationComplete, // External intersection observer integration elementRef: externalElementRef, isIntersecting: externalIsIntersecting }) => { const [animatedItems, setAnimatedItems] = useState(new Set()); // Only use fast START delay for individual component replays, keep itemSpacing normal const isIndividualComponentReplay = forceStart && replayKey > 0 && isIndividualReplay; // For individual replays, only reduce the initial start delay, keep item spacing normal const effectiveStartDelay = isIndividualComponentReplay ? INDIVIDUAL_REPLAY_START_DELAY : startDelay; const [shouldAnimateBlock, setShouldAnimateBlock] = useState(false); const [hasStarted, setHasStarted] = useState(false); const [hasCompleted, setHasCompleted] = useState(false); // Use external intersection observer data if provided, otherwise create our own const internalIntersectionResult = useIntersectionObserver(); const elementRef = externalElementRef || internalIntersectionResult.elementRef; const isIntersecting = externalIsIntersecting !== undefined ? externalIsIntersecting : internalIntersectionResult.isIntersecting; // Track component mount status to prevent state updates after unmount const isMountedRef = useRef(true); // Performance optimization: Check for reduced motion preference const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; // Reset animation state when replayKey changes useEffect(() => { setAnimatedItems(new Set()); setShouldAnimateBlock(false); setHasStarted(false); setHasCompleted(false); // Debug logging to trace animation resets if (typeof window !== 'undefined' && window.location.hostname === 'localhost') { console.log(`[useAnimationState] Animation reset for replay key:`, replayKey); } }, [replayKey]); // Track component mount status useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); // Use refs to store stable callback references const onAnimationStartRef = useRef(onAnimationStart); const onAnimationCompleteRef = useRef(onAnimationComplete); // Update refs when callbacks change useEffect(() => { onAnimationStartRef.current = onAnimationStart; }, [onAnimationStart]); useEffect(() => { onAnimationCompleteRef.current = onAnimationComplete; }, [onAnimationComplete]); useEffect(() => { // Start animations if either intersecting or force start is enabled const shouldStart = isIntersecting || forceStart; if (!shouldStart || animation === 'none' || !isMountedRef.current) return; // Performance optimization: Skip complex animations if reduced motion is preferred if (prefersReducedMotion) { // Instantly complete all animations for accessibility if (animation === 'block') { setShouldAnimateBlock(true); onAnimationStartRef.current?.(); onAnimationCompleteRef.current?.(); } else if (itemCount > 0) { // Set all items as animated immediately const allItems = new Set(Array.from({ length: itemCount }, (_, i) => i + 1)); setAnimatedItems(allItems); onAnimationStartRef.current?.(); onAnimationCompleteRef.current?.(); } return; } if (animation === 'block') { // Call animation start callback if (!hasStarted && isMountedRef.current) { setHasStarted(true); onAnimationStartRef.current?.(); } // Block animation with proper timing const timer = setTimeout(() => { if (isMountedRef.current) { setShouldAnimateBlock(true); // Call animation complete callback for block animations onAnimationCompleteRef.current?.(); } }, effectiveStartDelay); return () => clearTimeout(timer); } if ((animation === 'phrases' || animation === 'word' || animation === 'typewriter' || animation === 'word-typewriter') && itemCount > 0) { // Call animation start callback for first item if (!hasStarted && isMountedRef.current) { setHasStarted(true); const firstItemDelay = customTimingFunction ? customTimingFunction(1) : animation === 'phrases' ? getPhraseDelay(1, phraseStyles, itemSpacing) : 0; setTimeout(() => { if (isMountedRef.current) { onAnimationStartRef.current?.(); } }, effectiveStartDelay + firstItemDelay); } // Store timeout IDs for cleanup const timeouts = []; // Animate each item (phrase, word, or letter) with its individual delay for (let i = 1; i <= itemCount; i++) { let itemDelay; if (customTimingFunction) { // Use custom timing function for special cases like word-typewriter itemDelay = customTimingFunction(i); } else if (animation === 'phrases') { // Phrase animations use custom phrase delays itemDelay = getPhraseDelay(i, phraseStyles, itemSpacing); } else { // Word/typewriter use simple sequential spacing itemDelay = (i - 1) * itemSpacing; } const timeoutId = setTimeout(() => { // Only proceed if component is still mounted if (!isMountedRef.current) return; // Use requestAnimationFrame to ensure initial styles are rendered requestAnimationFrame(() => { // Double-check mount status after animation frame if (!isMountedRef.current) return; setAnimatedItems(prev => { const newItems = new Set([...prev, i]); // Check if this is the last item to animate - use ref to avoid stale closure if (newItems.size === itemCount && !hasCompleted && isMountedRef.current) { setHasCompleted(true); // Delay callback to next tick to avoid state update during render setTimeout(() => { if (isMountedRef.current) { onAnimationCompleteRef.current?.(); } }, 0); } return newItems; }); }); }, effectiveStartDelay + itemDelay); timeouts.push(timeoutId); } // Cleanup function to clear all timeouts return () => { timeouts.forEach(timeoutId => clearTimeout(timeoutId)); }; } // Default: no cleanup needed for other cases return undefined; }, [ isIntersecting, forceStart, replayKey, animation, startDelay, effectiveStartDelay, itemSpacing, itemCount, phraseStyles, customTimingFunction, hasStarted, hasCompleted, prefersReducedMotion ]); // Reset animation when component unmounts useEffect(() => { return () => { // Mark as unmounted and reset state isMountedRef.current = false; setAnimatedItems(new Set()); setShouldAnimateBlock(false); }; }, []); return { elementRef, animatedItems, shouldAnimateBlock }; };