Text Diff
A word- or sentence-level diff of two prose versions — one unified flow or two columns, with insertions and deletions marked by underline, strikethrough and screen-reader wording instead of colour alone.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/text-diff.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TextDiff" component: a word- or
sentence-level diff of two PROSE versions, rendered as one flow or as two
columns. lucide-react for the icons; no diff library — the match is your own LCS.
Contract
- forwardRef<HTMLDivElement>: the ref and every native div prop land on the root,
className merges into it.
- Props extend Omit<React.HTMLAttributes<HTMLDivElement>, "children"> and add:
before: string, after: string (required, plain text — this component never
parses HTML or Markdown),
view?: "unified" | "split" plus defaultView (default "unified") and
onViewChange,
granularity?: "word" | "sentence" plus defaultGranularity (default "word") and
onGranularityChange — both axes go through one small
useControllable(controlled, initial, onChange) helper, so either can be
controlled, uncontrolled, or merely observed,
controls?: boolean (default true) — the layout / unit / change-navigation bar,
labels?: Partial<{ before, after, added, removed, empty }>,
splitBreakpoint?: number (default 560).
- Also export the model builder, because the numbers are useful outside the UI:
buildTextDiff(before, after, granularity) => { segments, leadBefore, leadAfter,
changeCount, added, removed, unchanged, degraded, identical, whitespaceOnly },
where a segment is { kind: "equal" | "insert" | "delete", before: Token[],
after: Token[], change: number } and a Token is { text, space }.
Behavior
- Tokenizing: a token is a visible run PLUS the whitespace that followed it, so
the author's line breaks survive verbatim and that trailing space can be
printed outside the ins/del box — a tint smeared across the gap between two
words reads as one long change. Word tokens are runs of non-whitespace;
sentence tokens run up to ".", "!", "?" or "…" at a whitespace boundary, or up
to a newline. Keep each version's leading whitespace separately; the tokenizer
would otherwise drop it. Guard the scan against a zero-length match, or the
loop spins on one index.
- Matching keys: words match on exact spelling — in prose a case or punctuation
edit IS an edit. Sentences match with inner whitespace collapsed, so a sentence
that was only re-wrapped still matches itself.
- Diff: trim the common head and tail first, then run an LCS over the changed
middle only — one Uint32Array of (n+1)*(m+1) filled backwards, then walked
forward into equal / delete / insert ops. The trim is what keeps a two-word
edit inside a long essay off the matrix. Past ~250k cells, give up on the exact
match and report the middle as one wholesale delete + insert with
degraded: true, which the UI states in words rather than freezing the tab.
- Grouping: consecutive ops of the same kind become one segment, and a maximal
run of non-equal ops is ONE change — a replacement (delete then insert)
counts and navigates as a single stop, the way a human counts edits. Equal
segments keep BOTH sides' tokens so each column can print its own spacing.
- Verdicts, never silence: identical (equal character for character),
whitespaceOnly (they differ, yet no token moved) and degraded each render a
muted sentence above the body. "No changes" is a result, not an empty state.
- Change navigation: previous / next walk the changes and wrap around, with a
"3 / 7" counter. The active index is DERIVED — min(stored, changeCount - 1) —
because switching granularity can shrink the list under a cursor that was
already past its new end.
- Keyboard, body: ArrowRight / ArrowDown next change, ArrowLeft / ArrowUp
previous, Home first, End last, Escape hands focus back to the Next button.
Keyboard, switches: each is a radiogroup where the Arrow keys move AND select,
Home / End jump to the ends, and a roving tabindex keeps the pair to one Tab
stop.
- ARIA: every insertion is an <ins> and every deletion a <del>, each with
tabIndex={-1} so navigation can focus it, aria-current="true" while it is the
active change, and an sr-only "added: " / "removed: " before it plus
", end added." / ", end removed." after it — the implicit insertion and
deletion roles are not announced reliably, and colour alone is not an
encoding. The body is role="group" labelled with both version names; in split
view each column is its own role="group" labelled by its heading. A
permanently mounted role="status" region speaks "Change 3 of 7: 2 words
removed, 4 words added." and the new layout or unit after a switch — a live
region that appears together with its text is not announced.
- Focus is never dropped: the marks unmount when the view or the granularity
changes, so the handler records synchronously whether the body owned focus and
an effect after the commit re-focuses the anchor of the active change, falling
back to the body itself, which carries tabIndex={-1} for exactly that. Prev /
Next never take the native disabled attribute: with zero changes they are
aria-disabled with an early return in the handler, so a user standing on one is
not blurred to <body>.
- Empty and one-sided passages stay legible: a column with no tokens at all
(before="" on a first draft) prints the `empty` label instead of rendering as
a blank half, and where text exists only on the other side the opposite column
keeps a small aria-hidden caret, so the reader can see WHERE the passage was
inserted or removed.
- split degrades to stacked columns under splitBreakpoint pixels, measured by a
ResizeObserver on the root — a media query would answer for the window, and
this component usually lives inside a card. The same measurement drops the two
switches to icons only (their text stays as the accessible name), so the bar
keeps to one row in a narrow column. Disconnect the observer on unmount and
whenever splitBreakpoint changes.
- Reaching a change is focus({ preventScroll: true }) followed by
scrollIntoView({ block: "nearest" }), smooth only when
matchMedia("(prefers-reduced-motion: reduce)") does not match. Read that query
inside the gesture, so there is no listener to leak.
Rendering & styling
- Semantic tokens only: deletions bg-destructive/10 + text-destructive +
line-through + decoration-destructive; insertions bg-primary/10 +
text-foreground + underline + decoration-primary; the active change adds
ring-2 ring-ring ring-offset-1 ring-offset-background; headings, counters and
verdicts text-muted-foreground; the switch sits on bg-muted with the checked
option raised on bg-background.
- Marks are box-decoration-clone, so a change that wraps across two lines keeps
its box on both; the prose is whitespace-pre-wrap break-words, so pasted line
breaks and long URLs both behave.
- cn() merges every className; focus-visible:ring-2 ring-ring on every button and
on the body. Colour transitions carry motion-reduce:transition-none, and there
is no entrance animation at all — text that animates is text you cannot read.
Customization levers
- Density: the component ships no card of its own. Wrap it, or pass
className="rounded-lg border bg-card p-4" for a panel; the text size lives in
one proseClass constant that both layouts share.
- Sub-blocks are independent: controls={false} drops the whole bar so your own
toolbar can drive view and granularity, or keep the bar and delete the +N / −N
chips if that summary is noise on your surface.
- Encoding: recolour to var(--chart-2) / var(--chart-4) to match a report's
palette, but keep BOTH the underline and the strikethrough — they are the part
that survives greyscale, colour blindness and a screen reader.
- Matching rules are one function: normalise edge punctuation inside matchKey so
"refund." against "refund," stops counting as a change, or lowercase it for a
case-insensitive review pass.
- Granularity is just a pair of tokenizers: add "paragraph" by splitting on blank
lines, and nothing downstream needs to know what a token is.
- splitBreakpoint decides when two columns become one and when the switches go
icon-only; set it to 0 to force columns and full labels inside a layout you
already control.Concepts
- Token = a word plus its trailing space — whitespace rides along with the word instead of being tokenized on its own, which is what lets the diff reprint the author's line breaks exactly and still draw a tint that stops at the last letter.
- Trim, then LCS — the shared head and tail are stripped before the matrix is allocated, so the cost tracks the size of the edit, not the size of the essay; only a genuine wholesale rewrite can reach the cap that degrades the middle to one replacement.
- One change, two marks — a rewrite is a deletion immediately followed by an insertion, and counting it twice would make "7 changes" meaningless; the run is one ordinal, one navigation stop, one announcement.
- Colour is never the only channel — deletions are struck through, insertions underlined, and both carry an sr-only "added" / "removed" wrapper, so the diff survives a greyscale print, colour blindness and a screen reader that ignores
<ins>semantics. - Verdict instead of silence — identical text, a whitespace-only reflow and an input too large to match exactly each get a sentence of their own, because a diff that renders nothing is indistinguishable from a diff that is broken.
- Focus never falls to
<body>— switching view or granularity unmounts the mark the reader was standing on, so the handler records that synchronously and an effect re-focuses the same change afterwards, with the body itself as the deliberate fallback.
Drop Cap
The oversized opening letter, floated across N lines with its cap-top and baseline solved from the font's metrics — hangs an opening quotation mark and skips short paragraphs.
Grid Dots
A pure-CSS grid, dot or cross pattern container with a radial fade — a zero-JS decorative backdrop for hero and section surfaces.