Crossfades between changing strings with a blur-masked transition so the swap reads as one continuous element.
npx shadcn@latest add @paragon/text-morph"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/** Mirrors var(--ease-out) — motion transition arrays can't read CSS custom properties. */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Mirrors var(--ease-exit). */
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
export interface TextMorphProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The string to render. When it changes, old and new text crossfade in place. */
children: string;
/** Enter duration in seconds. The exit runs at half so old text never lingers. */
duration?: number;
/** Swaps text instantly, without the crossfade. */
static?: boolean;
}
/**
* Crossfades between changing strings so the swap reads as one continuous
* element. The outgoing text pops out of layout (popLayout) and overlaps the
* incoming text; a 2px blur masks the imperfect overlap while the exit runs
* at half duration with an exit ease, so the old string never fights for
* attention.
*/
export function TextMorph({
children,
duration = 0.2,
static: isStatic = false,
className,
...props
}: TextMorphProps) {
const reducedMotion = useReducedMotion();
if (isStatic) {
return (
<span className={cn("inline-block", className)} {...props}>
{children}
</span>
);
}
return (
<span className={cn("relative inline-block", className)} {...props}>
{/* Stable text for assistive tech — the animated layer briefly holds
both the old and new string during the crossfade. */}
<span className="sr-only">{children}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={children}
aria-hidden
className="inline-block"
initial={{
opacity: 0,
filter: reducedMotion ? "blur(0px)" : "blur(2px)",
}}
animate={{
opacity: 1,
filter: "blur(0px)",
transition: { duration, ease: EASE_OUT },
}}
exit={{
opacity: 0,
filter: reducedMotion ? "blur(0px)" : "blur(2px)",
transition: { duration: duration / 2, ease: EASE_EXIT },
}}
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}