aboutsummaryrefslogtreecommitdiff
path: root/apps/web/components/text-morph.tsx
blob: 467dc9993c9ec389daafc091828c26d551e970e9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
'use client';
import { cn } from '@lib/utils';
import { AnimatePresence, motion, type Transition, type Variants } from 'motion/react';
import { useMemo, useId } from 'react';

export type TextMorphProps = {
    children: string;
    as?: React.ElementType;
    className?: string;
    style?: React.CSSProperties;
    variants?: Variants;
    transition?: Transition;
};

export function TextMorph({
    children,
    as: Component = 'p',
    className,
    style,
    variants,
    transition,
}: TextMorphProps) {
    const uniqueId = useId();

    const characters = useMemo(() => {
        const charCounts: Record<string, number> = {};

        return children.split('').map((char) => {
            const lowerChar = char.toLowerCase();
            charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;

            return {
                id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
                label: char === ' ' ? '\u00A0' : char,
            };
        });
    }, [children, uniqueId]);

    const defaultVariants: Variants = {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0 },
    };

    const defaultTransition: Transition = {
        type: 'spring',
        stiffness: 280,
        damping: 18,
        mass: 0.3,
    };

    return (
        // @ts-expect-error - style is optional
        <Component className={cn(className)} aria-label={children} style={style}>
            <AnimatePresence mode='popLayout' initial={false}>
                {characters.map((character) => (
                    <motion.span
                        key={character.id}
                        layoutId={character.id}
                        className='inline-block'
                        aria-hidden='true'
                        initial='initial'
                        animate='animate'
                        exit='exit'
                        variants={variants || defaultVariants}
                        transition={transition || defaultTransition}
                    >
                        {character.label}
                    </motion.span>
                ))}
            </AnimatePresence>
        </Component>
    );
}