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