Text Effects

Inline Diff Text

Text that transitions old to new by crossfading only the changed words — unchanged words hold their identity and glide via motion layout — for live prices and status lines.

Install

npx shadcn@latest add @paragon/inline-diff-text

inline-diff-text.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface InlineDiffTextProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The current text. When it changes, only changed words animate. */
  children: string;
  /** Swap text instantly, without motion. */
  static?: boolean;
}

/**
 * Text that transitions old → new by crossfading only the words that
 * changed. Words are diffed by content: each word's key is its text plus its
 * occurrence index, so unchanged words keep their identity and glide to new
 * positions via motion layout while changed words crossfade in place behind
 * a 2px blur. Built for live-updating copy — prices, status lines, counts.
 *
 * The full current string is exposed to screen readers as one node; the
 * animated words are decorative. Reduced motion swaps words instantly.
 */
export function InlineDiffText({
  children,
  static: isStatic = false,
  className,
  ...props
}: InlineDiffTextProps) {
  const reducedMotion = useReducedMotion() ?? false;

  // Key = word + occurrence index: unchanged words keep stable keys across
  // updates (so layout moves them); changed words mint new keys (crossfade).
  const words = React.useMemo(() => {
    const counts = new Map<string, number>();
    return children
      .split(/\s+/)
      .filter(Boolean)
      .map((word) => {
        const n = counts.get(word) ?? 0;
        counts.set(word, n + 1);
        return { word, key: `${word}·${n}` };
      });
  }, [children]);

  if (isStatic) {
    return (
      <span className={className} {...props}>
        {children}
      </span>
    );
  }

  return (
    <span
      className={cn("relative inline-flex flex-wrap gap-x-[0.25em]", className)}
      {...props}
    >
      <span className="sr-only">{children}</span>
      <AnimatePresence mode="popLayout" initial={false}>
        {words.map(({ word, key }) => (
          <motion.span
            key={key}
            aria-hidden
            layout={reducedMotion ? false : "position"}
            initial={
              reducedMotion ? { opacity: 0 } : { opacity: 0, filter: "blur(2px)" }
            }
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={
              reducedMotion ? { opacity: 0 } : { opacity: 0, filter: "blur(2px)" }
            }
            transition={
              reducedMotion
                ? { duration: 0 }
                : { type: "spring", duration: 0.3, bounce: 0 }
            }
            className="inline-block"
          >
            {word}
          </motion.span>
        ))}
      </AnimatePresence>
    </span>
  );
}