aboutsummaryrefslogtreecommitdiff
path: root/apps/web/components/text-morph.tsx
blob: 65dab8c7214bbc208a26f77593a44f1bb03df4f0 (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
75
76
77
78
79
"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>
	)
}