Morphing Text
A Motion-powered text primitive that morphs matching characters between string updates.
texttext-morph
Ship with motion
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type TextMorphProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"children"
> & {
children: string | string[];
};
function getText(children: TextMorphProps["children"]) {
return Array.isArray(children) ? children.join("") : children;
}
function generateKeys(text: string) {
const charCount: Record<string, number> = {};
return text.split("").map((char) => {
charCount[char] ??= 0;
const key = `${char}-${charCount[char]}`;
charCount[char] += 1;
return { char, key };
});
}
export function TextMorph({ children, className, ...props }: TextMorphProps) {
const shouldReduceMotion = useReducedMotion();
const text = getText(children);
const textToDisplay = React.useMemo(() => generateKeys(text), [text]);
const transition = shouldReduceMotion
? { duration: 0 }
: ({
duration: 0.25,
type: "spring",
bounce: 0,
opacity: {
duration: 0.35,
type: "spring",
bounce: 0,
},
} as const);
return (
<span aria-label={text} className={cn("inline-block", className)} {...props}>
<AnimatePresence mode="popLayout" initial={false}>
{textToDisplay.map(({ char, key }) => (
<motion.span
key={key}
layoutId={shouldReduceMotion ? undefined : key}
aria-hidden="true"
className="inline-block text-inherit"
initial={shouldReduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={shouldReduceMotion ? undefined : { opacity: 0 }}
transition={transition}
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
</AnimatePresence>
</span>
);
}Installation
pnpm dlx shadcn@latest add https://ui.ericts.com/r/text-morph.json