staggered-text.tsx
edited
1import React, { useEffect, useRef, useState } from 'react';
2import { motion } from 'motion/react';
3
4type StaggeredToken =
5 | string
6 | React.ReactNode
7 | {
8 element: string | React.ReactNode;
9 delay?: number;
10 };
11
12interface StaggeredTextProps {
13 text: StaggeredToken[] | string;
14 className?: string;
15 offset?: number;
16 delay?: number;
17 duration?: number;
18 staggerDelay?: number;
19 once?: boolean;
20 as?: React.ElementType;
21}
22
23interface NormalizedToken {
24 element: React.ReactNode;
25 delay?: number;
26 isWhitespace?: boolean;
27}
28
29export default function StaggeredText({
30 text,
31 className = '',
32 offset: propOffset,
33 delay = 0,
34 duration = .15,
35 staggerDelay = 0.1,
36 once = false,
37 as: Component = 'div',
38}: StaggeredTextProps) {
39 if (typeof text === 'string') text = [text];
40 const ref = useRef<HTMLDivElement | null>(null);
41 const [offset, setOffset] = useState<number>(propOffset ?? 20);
42
43 // Get computed line-height if offset is not provided
44 useEffect(() => {
45 if (propOffset === undefined && ref.current) {
46 const computed = window.getComputedStyle(ref.current);
47 const lineHeight = computed.lineHeight * 9;
48
49 if (lineHeight === 'normal') {
50 const fontSize = parseFloat(computed.fontSize || '96');
51 setOffset(fontSize * 9.5); // Approx fallback
52 } else {
53 setOffset(parseFloat(lineHeight * 9));
54 }
55 }
56 }, [propOffset]);
57
58 // Normalize tokens into { element, optional delay, isWhitespace }
59 const normalizeTokens = (input: StaggeredToken[]): NormalizedToken[] => {
60 const result: NormalizedToken[] = [];
61
62 input.forEach((item) => {
63 if (typeof item === 'string') {
64 const parts = item.split(/(\s+)/g).filter((w) => w.length > 0);
65 parts.forEach((word) =>
66 result.push({
67 element: word,
68 isWhitespace: /\s+/.test(word),
69 })
70 );
71 } else if (
72 typeof item === 'object' &&
73 item !== null &&
74 'element' in item
75 ) {
76 result.push({
77 element: item.element,
78 delay: item.delay,
79 });
80 } else {
81 result.push({ element: item });
82 }
83 });
84
85 return result;
86 };
87
88 const tokens = normalizeTokens(text);
89
90 // Calculate per-token delays including custom pauses
91 let accumulatedDelay = delay;
92 const tokenDelays = tokens.map((token) => {
93 const thisDelay = accumulatedDelay;
94 accumulatedDelay += staggerDelay;
95 if (token.delay !== undefined) {
96 accumulatedDelay += token.delay; // Extra pause after this token
97 }
98 return thisDelay;
99 });
100
101 return (
102 <Component className={className} ref={ref}>
103 <span style={{ display: 'inline-block', whiteSpace: 'pre-wrap' }}>
104 {tokens.map((token, index) => {
105 if (token.isWhitespace) {
106 return <span key={`space-${index}`}>{token.element}</span>;
107 }
108
109 return (
110 <motion.span
111 key={`token-${index}`}
112 style={{ display: 'inline-block' }}
113 initial={{ opacity: 0, y: offset }}
114 whileInView={{ opacity: 1, y: 0 }}
115 transition={{
116 opacity: {
117 duration: 0,
118 delay: tokenDelays[index], // fade starts immediately
119 },
120 y: {
121 duration,
122 delay: tokenDelays[index] + 0.25, // y starts *after* opacity
123 type: 'tween',
124 },
125 }}
126 viewport={{ once }}
127 className="origin-bottom"
128 >
129 {token.element}
130 </motion.span>
131
132 );
133 })}
134 </span>
135 </Component>
136 );
137}