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.
npx shadcn@latest add @paragon/inline-diff-text"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>
);
}