import { useSprings, animated, SpringConfig } from '@react-spring/web'; import { useEffect, useRef, useState } from 'react'; interface SegmenterOptions { granularity?: 'grapheme' | 'word' | 'sentence'; localeMatcher?: 'lookup' | 'best fit'; } interface SegmentData { segment: string; index: number; input: string; isWordLike?: boolean; } interface Segments { [Symbol.iterator](): IterableIterator; } interface IntlSegmenter { segment(input: string): Segments; } interface IntlSegmenterConstructor { new (locales?: string | string[], options?: SegmenterOptions): IntlSegmenter; } declare global { interface Intl { Segmenter: IntlSegmenterConstructor; } } interface SplitTextProps { text?: string; className?: string; delay?: number; animationFrom?: { opacity: number; transform: string }; animationTo?: { opacity: number; transform: string }; easing?: SpringConfig['easing']; threshold?: number; rootMargin?: string; textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end'; onLetterAnimationComplete?: () => void; onLineCountChange?: (lineCount: number) => void; } const splitGraphemes = (text: string): string[] => { if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { const segmenter = new (Intl as typeof Intl & { Segmenter: IntlSegmenterConstructor }).Segmenter( 'en', { granularity: 'grapheme' }, ); const segments = segmenter.segment(text); return Array.from(segments).map((s: SegmentData) => s.segment); } else { return [...text]; } }; const SplitText: React.FC = ({ text = '', className = '', delay = 100, animationFrom = { opacity: 0, transform: 'translate3d(0,40px,0)' }, animationTo = { opacity: 1, transform: 'translate3d(0,0,0)' }, easing = (t: number) => t, threshold = 0.1, rootMargin = '-100px', textAlign = 'center', onLetterAnimationComplete, onLineCountChange, }) => { const words = text.split(' ').map(splitGraphemes); const letters = words.flat(); const [inView, setInView] = useState(false); const ref = useRef(null); const animatedCount = useRef(0); const [springs] = useSprings( letters.length, (i) => ({ from: animationFrom, to: inView ? async (next) => { await next(animationTo); animatedCount.current += 1; if (animatedCount.current === letters.length && onLetterAnimationComplete) { onLetterAnimationComplete(); } } : animationFrom, delay: i * delay, config: { easing }, }), [inView, text, delay, animationFrom, animationTo, easing, onLetterAnimationComplete], ); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setInView(true); if (ref.current) { observer.unobserve(ref.current); } } }, { threshold, rootMargin }, ); if (ref.current) { observer.observe(ref.current); } return () => observer.disconnect(); }, [threshold, rootMargin]); useEffect(() => { if (ref.current && inView) { const element = ref.current; setTimeout(() => { const lineHeight = parseInt(getComputedStyle(element).lineHeight) || parseInt(getComputedStyle(element).fontSize) * 1.2; const height = element.offsetHeight; const lines = Math.round(height / lineHeight); if (onLineCountChange) { onLineCountChange(lines); } }, 100); } }, [inView, text, onLineCountChange]); return ( <> {text} ); }; export default SplitText;