"use client";
import * as React from "react";
import { motion } from "motion/react";
export type TextDiffGranularity = "word" | "char";
export type TextDiffMode = "inline" | "split";
export interface TextDiffProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** The old text. */
from: string;
/** The new text. */
to: string;
/** Compare whole words (with spaces and punctuation as their own tokens) or single characters. */
granularity?: TextDiffGranularity;
/** One merged paragraph, or "Before" and "After" side by side (stacked in narrow containers). */
mode?: TextDiffMode;
/** Show insertions and deletions. Off shows the clean new text. The header switch toggles it too. */
showChanges?: boolean;
/** Animate each new diff: deletions strike through (or collapse), insertions type in. */
animate?: boolean;
/** Length of the whole transition in ms. */
duration?: number;
/** Header with the "+12 −5" summary, the Show changes switch and a replay button. */
toolbar?: boolean;
insertColor?: string;
deleteColor?: string;
beforeLabel?: string;
afterLabel?: string;
}
/* ---------------------------------- diff ---------------------------------- */
type EditType = "equal" | "insert" | "delete";
interface Run {
type: EditType;
text: string;
}
const WORD_TOKENS = /\s+|[\p{L}\p{N}_]+(?:['’-][\p{L}\p{N}_]+)*|[^\s\p{L}\p{N}_]/gu;
/** Past this much work (edit distance × width) the diff degrades to "replace everything". */
const MAX_WORK = 4_000_000;
function tokenize(text: string, granularity: TextDiffGranularity) {
return granularity === "char" ? Array.from(text) : (text.match(WORD_TOKENS) ?? []);
}
/** Myers' O((N+M)·D) diff over token arrays, with the common prefix and suffix trimmed first. */
function diff(a: string[], b: string[]): { type: EditType; token: string }[] {
let start = 0;
while (start < a.length && start < b.length && a[start] === b[start]) start++;
let endA = a.length;
let endB = b.length;
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
endA--;
endB--;
}
const A = a.slice(start, endA);
const B = b.slice(start, endB);
const N = A.length;
const M = B.length;
const max = N + M;
const middle: { type: EditType; token: string }[] = [];
if (max > 0) {
const offset = max + 1;
const v = new Int32Array(2 * max + 3);
const trace: Int32Array[] = [];
let found = false;
for (let d = 0; d <= max && !found; d++) {
if (d * v.length > MAX_WORK) break;
trace.push(v.slice());
for (let k = -d; k <= d; k += 2) {
let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? v[offset + k + 1] : v[offset + k - 1] + 1;
let y = x - k;
while (x < N && y < M && A[x] === B[y]) {
x++;
y++;
}
v[offset + k] = x;
if (x >= N && y >= M) {
found = true;
break;
}
}
}
if (!found) {
for (const token of A) middle.push({ type: "delete", token });
for (const token of B) middle.push({ type: "insert", token });
} else {
// Walk the trace backwards to recover the edit script.
let x = N;
let y = M;
for (let d = trace.length - 1; d >= 0; d--) {
const row = trace[d];
const k = x - y;
const prevK = k === -d || (k !== d && row[offset + k - 1] < row[offset + k + 1]) ? k + 1 : k - 1;
const prevX = row[offset + prevK];
const prevY = prevX - prevK;
while (x > prevX && y > prevY) {
middle.push({ type: "equal", token: A[x - 1] });
x--;
y--;
}
if (d > 0) {
if (x === prevX) middle.push({ type: "insert", token: B[y - 1] });
else middle.push({ type: "delete", token: A[x - 1] });
}
x = prevX;
y = prevY;
}
middle.reverse();
}
}
return [
...a.slice(0, start).map((token) => ({ type: "equal" as const, token })),
...middle,
...a.slice(endA).map((token) => ({ type: "equal" as const, token })),
];
}
type Block = { kind: "equal"; text: string } | { kind: "change"; del: string; ins: string };
/**
* Groups edits into runs and tidies them for reading. An unchanged stretch between two changes is
* folded into them when it is only whitespace or, at character level, when it is no longer than the
* changes on either side (so "colour → color" stays a one-letter edit, but a rewritten sentence reads
* as words rather than confetti). Inside every change block deletions come before insertions.
*/
function toRuns(edits: { type: EditType; token: string }[], granularity: TextDiffGranularity): Run[] {
const blocks: Block[] = [];
for (const e of edits) {
const last = blocks[blocks.length - 1];
if (e.type === "equal") {
if (last?.kind === "equal") last.text += e.token;
else blocks.push({ kind: "equal", text: e.token });
continue;
}
let change = last?.kind === "change" ? last : null;
if (!change) {
change = { kind: "change", del: "", ins: "" };
blocks.push(change);
}
if (e.type === "delete") change.del += e.token;
else change.ins += e.token;
}
const merged: Block[] = [];
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const prev = merged[merged.length - 1];
const next = blocks[i + 1];
if (block.kind === "equal" && prev?.kind === "change" && next?.kind === "change") {
const size = block.text.length;
const small =
!block.text.trim() ||
(granularity === "char" && size <= Math.max(prev.del.length, prev.ins.length) && size <= Math.max(next.del.length, next.ins.length));
if (small) {
prev.del += block.text + next.del;
prev.ins += block.text + next.ins;
i++;
continue;
}
}
merged.push(block);
}
const runs: Run[] = [];
for (const block of merged) {
if (block.kind === "equal") runs.push({ type: "equal", text: block.text });
else {
if (block.del) runs.push({ type: "delete", text: block.del });
if (block.ins) runs.push({ type: "insert", text: block.ins });
}
}
return runs;
}
function countUnits(text: string, granularity: TextDiffGranularity) {
return granularity === "char" ? Array.from(text.replace(/\s/g, "")).length : (text.match(/[\p{L}\p{N}_]+/gu) ?? []).length;
}
/* -------------------------------- component -------------------------------- */
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const CLIP_OPEN = "inset(-0.35em 0% -0.35em 0%)";
const CLIP_SHUT = "inset(-0.35em 100% -0.35em 0%)";
/**
* An animated text diff. Computes a word- or character-level Myers diff between two strings and
* renders it inline or side by side, with insertions underlined and deletions struck through (plus
* screen-reader labels, so colour is never the only cue). Each new pair animates: deletions strike
* through (or collapse when changes are hidden) and insertions type in, then a "+12 −5" summary.
*/
export function TextDiff({
from,
to,
granularity = "word",
mode = "inline",
showChanges: showChangesProp = true,
animate = true,
duration = 900,
toolbar = true,
insertColor = "#22A06B",
deleteColor = "#E5484D",
beforeLabel = "Before",
afterLabel = "After",
className = "",
style,
...rest
}: TextDiffProps) {
const reduced = usePrefersReducedMotion();
const runs = React.useMemo(() => toRuns(diff(tokenize(from, granularity), tokenize(to, granularity)), granularity), [from, to, granularity]);
// Every new pair of strings (or a replay) is a new "generation": its change chunks remount and animate.
const pairKey = `${granularity}\u0000${from}\u0000${to}`;
const [generation, setGeneration] = React.useState(0);
const [prevPair, setPrevPair] = React.useState(pairKey);
if (prevPair !== pairKey) {
setPrevPair(pairKey);
setGeneration((g) => g + 1);
}
const [showChanges, setShowChanges] = React.useState(showChangesProp);
const [prevShowProp, setPrevShowProp] = React.useState(showChangesProp);
if (prevShowProp !== showChangesProp) {
setPrevShowProp(showChangesProp);
setShowChanges(showChangesProp);
}
const inserted = runs.filter((r) => r.type === "insert").reduce((n, r) => n + countUnits(r.text, granularity), 0);
const deleted = runs.filter((r) => r.type === "delete").reduce((n, r) => n + countUnits(r.text, granularity), 0);
const unit = granularity === "char" ? "character" : "word";
const plural = (n: number) => `${n} ${unit}${n === 1 ? "" : "s"}`;
const play = animate && !reduced && generation > 0;
const seconds = duration / 1000;
const deleteCount = runs.filter((r) => r.type === "delete").length;
const insertCount = runs.filter((r) => r.type === "insert").length;
// Deletions take the first ~40% of the transition, insertions type in across the rest.
const chunkIn = Math.min(0.32, seconds * 0.3);
const insertStagger = insertCount > 1 ? Math.min(0.09, (seconds * 0.6 - chunkIn) / (insertCount - 1)) : 0;
const deleteStagger = deleteCount > 1 ? Math.min(0.06, (seconds * 0.35) / (deleteCount - 1)) : 0;
const renderRuns = (pane: "inline" | "before" | "after") => {
let delIndex = 0;
let insIndex = 0;
return runs.map((run, i) => {
if (run.type === "equal") return <React.Fragment key={`e-${i}`}>{run.text}</React.Fragment>;
const isDelete = run.type === "delete";
if ((pane === "before" && !isDelete) || (pane === "after" && isDelete)) return null;
const order = isDelete ? delIndex++ : insIndex++;
const chunks = run.text.match(/\S+\s*|\s+/g) ?? [run.text];
// Inline view with changes hidden: deletions collapse away and are hidden from assistive tech.
const collapsed = isDelete && pane === "inline" && !showChanges;
const Tag = isDelete ? "del" : "ins";
const delay = isDelete ? order * deleteStagger : seconds * 0.35 + order * insertStagger;
const color = isDelete ? deleteColor : insertColor;
const marked = showChanges;
return (
<Tag
key={`${generation}-${i}`}
aria-hidden={collapsed || undefined}
// A hairline gap keeps "old" and "new" apart when a deletion is directly followed by its replacement.
className={`no-underline transition-[margin] duration-300 ${pane === "inline" && marked && isDelete && runs[i + 1]?.type === "insert" ? "mr-[0.3em]" : ""}`}
>
{marked ? <span className="sr-only">{isDelete ? "[deleted: " : "[inserted: "}</span> : null}
{chunks.map((chunk, c) => (
<motion.span
key={c}
className={`inline-block whitespace-pre transition-colors duration-300 ${
isDelete
? `line-through decoration-[0.09em] ${marked ? "[background:linear-gradient(color-mix(in_srgb,var(--td-del)_14%,transparent),color-mix(in_srgb,var(--td-del)_14%,transparent))_0_50%/100%_1.4em_no-repeat] text-zinc-500 dark:text-zinc-400" : ""}`
: `underline decoration-[0.09em] underline-offset-[0.22em] ${marked ? "[background:linear-gradient(color-mix(in_srgb,var(--td-ins)_15%,transparent),color-mix(in_srgb,var(--td-ins)_15%,transparent))_0_50%/100%_1.4em_no-repeat]" : ""}`
}`}
initial={
play
? isDelete
? { textDecorationColor: "rgba(0,0,0,0)", width: "auto", opacity: 1, clipPath: CLIP_OPEN }
: { width: 0, opacity: 0, clipPath: CLIP_SHUT, textDecorationColor: marked ? color : "rgba(0,0,0,0)" }
: false
}
animate={
isDelete
? collapsed
? { width: 0, opacity: 0, clipPath: CLIP_OPEN, textDecorationColor: color }
: { width: "auto", opacity: 1, clipPath: CLIP_OPEN, textDecorationColor: marked ? color : "rgba(0,0,0,0)" }
: { width: "auto", opacity: 1, clipPath: CLIP_OPEN, textDecorationColor: marked ? color : "rgba(0,0,0,0)" }
}
transition={
reduced || !animate
? { duration: 0 }
: {
duration: isDelete ? Math.min(0.4, seconds * 0.4) : chunkIn,
delay: play ? delay + c * 0.02 : 0,
ease: [0.22, 1, 0.36, 1],
}
}
>
{chunk}
</motion.span>
))}
{marked ? <span className="sr-only">]</span> : null}
</Tag>
);
});
};
const text = "text-[15px] leading-[1.75] text-zinc-800 dark:text-zinc-200";
const paneLabel = "mb-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400";
return (
<div
{...rest}
className={`@container w-full min-w-0 rounded-2xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-[#17191c] ${className}`}
style={{ ...style, ["--td-ins" as string]: insertColor, ["--td-del" as string]: deleteColor }}
>
{toolbar ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-zinc-900/10 px-4 py-2.5 dark:border-white/10">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
Diff · {granularity === "char" ? "chars" : "words"}
</span>
<span className="flex items-center gap-2 font-mono text-[11px] tabular-nums" aria-hidden="true">
<span style={{ color: insertColor }}>+{inserted}</span>
<span style={{ color: deleteColor }}>−{deleted}</span>
</span>
<span className="sr-only">{`${plural(inserted)} inserted, ${plural(deleted)} deleted`}</span>
<span className="ml-auto flex items-center gap-1">
<button
type="button"
role="switch"
aria-checked={showChanges}
onClick={() => setShowChanges((s) => !s)}
className="group flex h-7 items-center gap-2 rounded-md px-2 text-[12px] text-zinc-600 outline-hidden transition-colors hover:bg-zinc-900/[0.05] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--td-ins)] dark:text-zinc-300 dark:hover:bg-white/[0.07]"
>
<span
aria-hidden="true"
className="relative h-4 w-7 rounded-full bg-zinc-900/15 transition-colors group-aria-checked:bg-[var(--td-ins)] dark:bg-white/15"
>
<span className="absolute left-0.5 top-0.5 size-3 rounded-full bg-white shadow-sm transition-transform group-aria-checked:translate-x-3" />
</span>
Show changes
</button>
{animate ? (
<button
type="button"
aria-label="Replay transition"
title="Replay"
onClick={() => setGeneration((g) => g + 1)}
className="grid size-7 place-items-center rounded-md text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--td-ins)] dark:text-zinc-400 dark:hover:bg-white/[0.07] dark:hover:text-white"
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.5 8a5.5 5.5 0 1 0 1.6-3.9" />
<path d="M2.5 2.5v3h3" />
</svg>
</button>
) : null}
</span>
</div>
) : null}
{mode === "split" ? (
<div className="grid grid-cols-1 @lg:grid-cols-2">
<div className="min-w-0 border-b border-zinc-900/10 px-5 py-4 @lg:border-b-0 @lg:border-r dark:border-white/10">
<p className={paneLabel}>{beforeLabel}</p>
<p className={text}>{renderRuns("before")}</p>
</div>
<div className="min-w-0 px-5 py-4">
<p className={paneLabel}>{afterLabel}</p>
<p className={text}>{renderRuns("after")}</p>
</div>
</div>
) : (
<p className={`px-5 py-4 ${text}`}>{renderRuns("inline")}</p>
)}
{/* Announces the summary of each new diff. */}
<p aria-live="polite" className="sr-only">
{generation > 0 ? `${plural(inserted)} inserted, ${plural(deleted)} deleted` : ""}
</p>
</div>
);
}