A text-morph component from Motion Primitives.
1'use client';
2import { cn } from '@/lib/utils';
3import { AnimatePresence, motion, Transition, Variants } from 'motion/react';
4import { useMemo, useId } from 'react';
5
6export type TextMorphProps = {
7 children: string;
8 as?: React.ElementType;
9 className?: string;
10 style?: React.CSSProperties;
11 variants?: Variants;
12 transition?: Transition;
13};
14
15export function TextMorph({
16 children,
17 as: Component = 'p',
18 className,
19 style,
20 variants,
21 transition,
22}: TextMorphProps) {
23 const uniqueId = useId();
24
25 const characters = useMemo(() => {
26 const charCounts: Record<string, number> = {};
27
28 return children.split('').map((char) => {
29 const lowerChar = char.toLowerCase();
30 charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;
31
32 return {
33 id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
34 label: char === ' ' ? '\u00A0' : char,
35 };
36 });
37 }, [children, uniqueId]);
38
39 const defaultVariants: Variants = {
40 initial: { opacity: 0 },
41 animate: { opacity: 1 },
42 exit: { opacity: 0 },
43 };
44
45 const defaultTransition: Transition = {
46 type: 'spring',
47 stiffness: 280,
48 damping: 18,
49 mass: 0.3,
50 };
51
52 return (
53 <Component className={cn(className)} aria-label={children} style={style}>
54 <AnimatePresence mode='popLayout' initial={false}>
55 {characters.map((character) => (
56 <motion.span
57 key={character.id}
58 layoutId={character.id}
59 className='inline-block'
60 aria-hidden='true'
61 initial='initial'
62 animate='animate'
63 exit='exit'
64 variants={variants || defaultVariants}
65 transition={transition || defaultTransition}
66 >
67 {character.label}
68 </motion.span>
69 ))}
70 </AnimatePresence>
71 </Component>
72 );
73}
74