Text Effects

Text Morph

Crossfades between changing strings with a blur-masked transition so the swap reads as one continuous element.

Install

npx shadcn@latest add @paragon/text-morph

text-morph.tsx

"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>
  );
}