motion-primitives
components
ui
animation
motion

text-morph

Animates text by morphing shared letters between words, creating fluid transitions.

animated
text
transition
View Docs

Source Code

Files
text-morph.tsx
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, index) => {
29      const lowerChar = char.toLowerCase();
30      charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;
31
32      return {
33        id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
34        label:
35          char === ' '
36            ? '\u00A0'
37            : index === 0
38              ? char.toUpperCase()
39              : lowerChar,
40      };
41    });
42  }, [children, uniqueId]);
43
44  const defaultVariants: Variants = {
45    initial: { opacity: 0 },
46    animate: { opacity: 1 },
47    exit: { opacity: 0 },
48  };
49
50  const defaultTransition: Transition = {
51    type: 'spring',
52    stiffness: 280,
53    damping: 18,
54    mass: 0.3,
55  };
56
57  return (
58    <Component className={cn(className)} aria-label={children} style={style}>
59      <AnimatePresence mode='popLayout' initial={false}>
60        {characters.map((character) => (
61          <motion.span
62            key={character.id}
63            layoutId={character.id}
64            className='inline-block'
65            aria-hidden='true'
66            initial='initial'
67            animate='animate'
68            exit='exit'
69            variants={variants || defaultVariants}
70            transition={transition || defaultTransition}
71          >
72            {character.label}
73          </motion.span>
74        ))}
75      </AnimatePresence>
76    </Component>
77  );
78}
79