@erosnicolau/animated-text
Version:
Advanced React animated text component with comprehensive animation effects
192 lines (191 loc) • 9.04 kB
JavaScript
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
};
};