Easily animate text content with various effects.
1'use client';
2import { cn } from '@/lib/utils';
3import {
4 AnimatePresence,
5 motion,
6 TargetAndTransition,
7 Transition,
8 Variant,
9 Variants,
10} from 'motion/react';
11import React from 'react';
12
13export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide';
14
15export type PerType = 'word' | 'char' | 'line';
16
17export type TextEffectProps = {
18 children: string;
19 per?: PerType;
20 as?: keyof React.JSX.IntrinsicElements;
21 variants?: {
22 container?: Variants;
23 item?: Variants;
24 };
25 className?: string;
26 preset?: PresetType;
27 delay?: number;
28 speedReveal?: number;
29 speedSegment?: number;
30 trigger?: boolean;
31 onAnimationComplete?: () => void;
32 onAnimationStart?: () => void;
33 segmentWrapperClassName?: string;
34 containerTransition?: Transition;
35 segmentTransition?: Transition;
36 style?: React.CSSProperties;
37};
38
39const defaultStaggerTimes: Record<PerType, number> = {
40 char: 0.03,
41 word: 0.05,
42 line: 0.1,
43};
44
45const defaultContainerVariants: Variants = {
46 hidden: { opacity: 0 },
47 visible: {
48 opacity: 1,
49 transition: {
50 staggerChildren: 0.05,
51 },
52 },
53 exit: {
54 transition: { staggerChildren: 0.05, staggerDirection: -1 },
55 },
56};
57
58const defaultItemVariants: Variants = {
59 hidden: { opacity: 0 },
60 visible: {
61 opacity: 1,
62 },
63 exit: { opacity: 0 },
64};
65
66const presetVariants: Record<
67 PresetType,
68 { container: Variants; item: Variants }
69> = {
70 blur: {
71 container: defaultContainerVariants,
72 item: {
73 hidden: { opacity: 0, filter: 'blur(12px)' },
74 visible: { opacity: 1, filter: 'blur(0px)' },
75 exit: { opacity: 0, filter: 'blur(12px)' },
76 },
77 },
78 'fade-in-blur': {
79 container: defaultContainerVariants,
80 item: {
81 hidden: { opacity: 0, y: 20, filter: 'blur(12px)' },
82 visible: { opacity: 1, y: 0, filter: 'blur(0px)' },
83 exit: { opacity: 0, y: 20, filter: 'blur(12px)' },
84 },
85 },
86 scale: {
87 container: defaultContainerVariants,
88 item: {
89 hidden: { opacity: 0, scale: 0 },
90 visible: { opacity: 1, scale: 1 },
91 exit: { opacity: 0, scale: 0 },
92 },
93 },
94 fade: {
95 container: defaultContainerVariants,
96 item: {
97 hidden: { opacity: 0 },
98 visible: { opacity: 1 },
99 exit: { opacity: 0 },
100 },
101 },
102 slide: {
103 container: defaultContainerVariants,
104 item: {
105 hidden: { opacity: 0, y: 20 },
106 visible: { opacity: 1, y: 0 },
107 exit: { opacity: 0, y: 20 },
108 },
109 },
110};
111
112const AnimationComponent: React.FC<{
113 segment: string;
114 variants: Variants;
115 per: 'line' | 'word' | 'char';
116 segmentWrapperClassName?: string;
117}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
118 const content =
119 per === 'line' ? (
120 <motion.span variants={variants} className='block'>
121 {segment}
122 </motion.span>
123 ) : per === 'word' ? (
124 <motion.span
125 aria-hidden='true'
126 variants={variants}
127 className='inline-block whitespace-pre'
128 >
129 {segment}
130 </motion.span>
131 ) : (
132 <motion.span className='inline-block whitespace-pre'>
133 {segment.split('').map((char, charIndex) => (
134 <motion.span
135 key={`char-${charIndex}`}
136 aria-hidden='true'
137 variants={variants}
138 className='inline-block whitespace-pre'
139 >
140 {char}
141 </motion.span>
142 ))}
143 </motion.span>
144 );
145
146 if (!segmentWrapperClassName) {
147 return content;
148 }
149
150 const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block';
151
152 return (
153 <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
154 {content}
155 </span>
156 );
157});
158
159AnimationComponent.displayName = 'AnimationComponent';
160
161const splitText = (text: string, per: 'line' | 'word' | 'char') => {
162 if (per === 'line') return text.split('\n');
163 return text.split(/(\s+)/);
164};
165
166const hasTransition = (
167 variant: Variant
168): variant is TargetAndTransition & { transition?: Transition } => {
169 return (
170 typeof variant === 'object' && variant !== null && 'transition' in variant
171 );
172};
173
174const createVariantsWithTransition = (
175 baseVariants: Variants,
176 transition?: Transition & { exit?: Transition }
177): Variants => {
178 if (!transition) return baseVariants;
179
180 const { exit: _, ...mainTransition } = transition;
181
182 return {
183 ...baseVariants,
184 visible: {
185 ...baseVariants.visible,
186 transition: {
187 ...(hasTransition(baseVariants.visible)
188 ? baseVariants.visible.transition
189 : {}),
190 ...mainTransition,
191 },
192 },
193 exit: {
194 ...baseVariants.exit,
195 transition: {
196 ...(hasTransition(baseVariants.exit)
197 ? baseVariants.exit.transition
198 : {}),
199 ...mainTransition,
200 staggerDirection: -1,
201 },
202 },
203 };
204};
205
206export function TextEffect({
207 children,
208 per = 'word',
209 as = 'p',
210 variants,
211 className,
212 preset = 'fade',
213 delay = 0,
214 speedReveal = 1,
215 speedSegment = 1,
216 trigger = true,
217 onAnimationComplete,
218 onAnimationStart,
219 segmentWrapperClassName,
220 containerTransition,
221 segmentTransition,
222 style,
223}: TextEffectProps) {
224 const segments = splitText(children, per);
225 const MotionTag = motion[as as keyof typeof motion] as typeof motion.div;
226
227 const baseVariants = preset
228 ? presetVariants[preset]
229 : { container: defaultContainerVariants, item: defaultItemVariants };
230
231 const stagger = defaultStaggerTimes[per] / speedReveal;
232
233 const baseDuration = 0.3 / speedSegment;
234
235 const customStagger = hasTransition(variants?.container?.visible ?? {})
236 ? (variants?.container?.visible as TargetAndTransition).transition
237 ?.staggerChildren
238 : undefined;
239
240 const customDelay = hasTransition(variants?.container?.visible ?? {})
241 ? (variants?.container?.visible as TargetAndTransition).transition
242 ?.delayChildren
243 : undefined;
244
245 const computedVariants = {
246 container: createVariantsWithTransition(
247 variants?.container || baseVariants.container,
248 {
249 staggerChildren: customStagger ?? stagger,
250 delayChildren: customDelay ?? delay,
251 ...containerTransition,
252 exit: {
253 staggerChildren: customStagger ?? stagger,
254 staggerDirection: -1,
255 },
256 }
257 ),
258 item: createVariantsWithTransition(variants?.item || baseVariants.item, {
259 duration: baseDuration,
260 ...segmentTransition,
261 }),
262 };
263
264 return (
265 <AnimatePresence mode='popLayout'>
266 {trigger && (
267 <MotionTag
268 initial='hidden'
269 animate='visible'
270 exit='exit'
271 variants={computedVariants.container}
272 className={className}
273 onAnimationComplete={onAnimationComplete}
274 onAnimationStart={onAnimationStart}
275 style={style}
276 >
277 {per !== 'line' ? <span className='sr-only'>{children}</span> : null}
278 {segments.map((segment, index) => (
279 <AnimationComponent
280 key={`${per}-${index}-${segment}`}
281 segment={segment}
282 variants={computedVariants.item}
283 per={per}
284 segmentWrapperClassName={segmentWrapperClassName}
285 />
286 ))}
287 </MotionTag>
288 )}
289 </AnimatePresence>
290 );
291}
292