Text

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowDown, ArrowUp, Columns2, Pilcrow, Rows3, TriangleAlert, WholeWord } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Prose diff
 *
 * Two drafts of the same paragraph are not two files. The edit is almost always
 * "swapped a number and rewrote one sentence", so a line diff reports the whole
 * paragraph as changed and tells the reader nothing. The unit here is a WORD (or
 * a SENTENCE) plus the whitespace that followed it — the whitespace rides along
 * so the author's line breaks survive verbatim, and it is rendered OUTSIDE the

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/text-diff.json

Prompt

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.

On This Page