Developer
Text diff
Shows what changed between two strings, word by word or character by character, inline or old above new.
- Built on
<del><ins>color-mix()- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.8 kB
- Dependencies
- React
Retry failed webhooks removed: 3 added: 5 times over removed: 1 hour added: 24 hours
Try its options
Loading its options.
Code
import { TextDiff } from "@/components/equip/text-diff";
export default function Demo() {
return (
<p style={{ width: 200, margin: 0, fontSize: 14, lineHeight: 1.6 }}>
<TextDiff
before="Retry failed webhooks 3 times over 1 hour"
after="Retry failed webhooks 5 times over 24 hours"
/>
</p>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<p style="width:200px;margin:0;font-size:14px;line-height:1.6">
<span class="eq-text-diff" data-granularity="word" data-mode="inline">Retry failed webhooks <del class="eq-text-diff-removed"><span class="eq-text-diff-label">removed: </span>3</del><ins class="eq-text-diff-added"><span class="eq-text-diff-label"> added: </span>5</ins> times over <del class="eq-text-diff-removed"><span class="eq-text-diff-label">removed: </span>1 hour</del><ins class="eq-text-diff-added"><span class="eq-text-diff-label"> added: </span>24 hours</ins></span>
</p>Examples
Character by character
For identifiers and config values, where a word diff would strike the whole value for one changed digit.
- Region
- eu-west-
removed: 1added: 2 - Instance type
- m6i. added: xlarge
- Image tag
- api:2024.1
removed: 1added: 2.removed: 3added: 0
Code
import { TextDiff } from "@/components/equip/text-diff";
const changes = [
{ setting: "Region", before: "eu-west-1", after: "eu-west-2" },
{ setting: "Instance type", before: "m6i.large", after: "m6i.xlarge" },
{ setting: "Image tag", before: "api:2024.11.3", after: "api:2024.12.0" },
];
export default function Example() {
return (
<dl style={{ display: "grid", gridTemplateColumns: "auto auto", gap: "6px 16px", margin: 0, fontSize: 13 }}>
{changes.map((c) => (
<div key={c.setting} style={{ display: "contents" }}>
<dt style={{ color: "var(--eq-ink-2)" }}>{c.setting}</dt>
<dd style={{ margin: 0, fontFamily: "var(--eq-font-mono)", fontVariationSettings: "var(--eq-font-mono-settings)" }}>
<TextDiff granularity="char" before={c.before} after={c.after} />
</dd>
</div>
))}
</dl>
);
}HTMLworks in any framework, with the part's CSS
<dl style="display:grid;grid-template-columns:auto auto;gap:6px 16px;margin:0;font-size:13px">
<div style="display:contents">
<dt style="color:var(--eq-ink-2)">Region</dt>
<dd style="margin:0;font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">
<span class="eq-text-diff" data-granularity="char" data-mode="inline">eu-west-<del class="eq-text-diff-removed"><span class="eq-text-diff-label"> removed: </span>1</del><ins class="eq-text-diff-added"><span class="eq-text-diff-label"> added: </span>2</ins></span>
</dd>
</div>
<div style="display:contents">
<dt style="color:var(--eq-ink-2)">Instance type</dt>
<dd style="margin:0;font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">
<span class="eq-text-diff" data-granularity="char" data-mode="inline">m6i.<ins class="eq-text-diff-added"><span class="eq-text-diff-label"> added: </span>x</ins>large</span>
</dd>
</div>
<div style="display:contents">
<dt style="color:var(--eq-ink-2)">Image tag</dt>
<dd style="margin:0;font-family:var(--eq-font-mono);font-variation-settings:var(--eq-font-mono-settings)">
<span class="eq-text-diff" data-granularity="char" data-mode="inline">api:2024.1<del class="eq-text-diff-removed"><span class="eq-text-diff-label"> removed: </span>1</del><ins class="eq-text-diff-added"><span class="eq-text-diff-label"> added: </span>2</ins>.<del class="eq-text-diff-removed"><span class="eq-text-diff-label"> removed: </span>3</del><ins class="eq-text-diff-added"><span class="eq-text-diff-label"> added: </span>0</ins></span>
</dd>
</div>
</dl>Split, old above new
Each line reads as a whole sentence, which suits a confirmation step where people check the new rule before saving.
You are about to change the escalation rule
Before:Code
import { TextDiff } from "@/components/equip/text-diff";
export default function Example() {
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 340, fontSize: 14, lineHeight: 1.5 }}>
<p style={{ margin: 0, fontWeight: 650 }}>You are about to change the escalation rule</p>
<TextDiff
mode="split"
before="Email the on-call engineer after 10 minutes without acknowledgement."
after="Page the on-call engineer and the team lead after 5 minutes without acknowledgement."
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:8px;width:100%;max-width:340px;font-size:14px;line-height:1.5">
<p style="margin:0;font-weight:650">You are about to change the escalation rule</p>
<span class="eq-text-diff" data-granularity="word" data-mode="split">
<span class="eq-text-diff-line" data-side="before">
<span class="eq-text-diff-sign" aria-hidden="true">−</span>
<span class="eq-text-diff-label">Before: </span>
<span class="eq-text-diff-text"><del class="eq-text-diff-removed"><span class="eq-text-diff-label">removed: </span>Email</del> the on-call engineer after <del class="eq-text-diff-removed"><span class="eq-text-diff-label">removed: </span>10</del> minutes without acknowledgement.</span>
</span>
<span class="eq-text-diff-line" data-side="after">
<span class="eq-text-diff-sign" aria-hidden="true">+</span>
<span class="eq-text-diff-label">After: </span>
<span class="eq-text-diff-text"><ins class="eq-text-diff-added"><span class="eq-text-diff-label">added: </span>Page</ins> the on-call engineer <ins class="eq-text-diff-added"><span class="eq-text-diff-label">added: </span>and the team lead </ins>after <ins class="eq-text-diff-added"><span class="eq-text-diff-label">added: </span>5</ins> minutes without acknowledgement.</span>
</span>
</span>
</div>In an audit log
Screen readers hear each change as removed and added text, so the log makes sense without seeing the colours.
- Priya Raman changed the billing contact at 14:02
removed: financeadded: accounts-payable@example.com - Tomás Ferreira changed the workspace name at 09:47Larder
removed: Platformadded: Infrastructure Team
Code
import { TextDiff } from "@/components/equip/text-diff";
const entries = [
{
who: "Priya Raman",
what: "billing contact",
when: "14:02",
before: "finance@example.com",
after: "accounts-payable@example.com",
},
{
who: "Tomás Ferreira",
what: "workspace name",
when: "09:47",
before: "Larder Platform Team",
after: "Larder Infrastructure Team",
},
];
export default function Example() {
return (
<ul style={{ display: "grid", gap: 12, width: "100%", maxWidth: 360, margin: 0, padding: 0, listStyle: "none", fontSize: 13, lineHeight: 1.5 }}>
{entries.map((e) => (
<li key={e.when} style={{ display: "grid", gap: 2 }}>
<span>
<strong style={{ fontWeight: 650 }}>{e.who}</strong> changed the {e.what}
<span style={{ color: "var(--eq-ink-3)" }}> at {e.when}</span>
</span>
<TextDiff before={e.before} after={e.after} />
</li>
))}
</ul>
);
}HTMLworks in any framework, with the part's CSS
<ul style="display:grid;gap:12px;width:100%;max-width:360px;margin:0;padding:0;list-style:none;font-size:13px;line-height:1.5">
<li style="display:grid;gap:2px">
<span><strong style="font-weight:650">Priya Raman</strong> changed the billing contact<span style="color:var(--eq-ink-3)"> at 14:02</span></span>
<span class="eq-text-diff" data-granularity="word" data-mode="inline"><del class="eq-text-diff-removed"><span class="eq-text-diff-label">removed: </span>finance</del><ins class="eq-text-diff-added"><span class="eq-text-diff-label"> added: </span>accounts-payable</ins>@example.com</span>
</li>
<li style="display:grid;gap:2px">
<span><strong style="font-weight:650">Tomás Ferreira</strong> changed the workspace name<span style="color:var(--eq-ink-3)"> at 09:47</span></span>
<span class="eq-text-diff" data-granularity="word" data-mode="inline">Larder <del class="eq-text-diff-removed"><span class="eq-text-diff-label">removed: </span>Platform</del><ins class="eq-text-diff-added"><span class="eq-text-diff-label"> added: </span>Infrastructure</ins> Team</span>
</li>
</ul>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
before | string | none | The old text. |
after | string | none | The new text. |
granularity | "word" | "char" | "word" | Compare words, with each punctuation mark on its own, or single characters for IDs and config values. |
mode | "inline" | "split" | "inline" | Inline weaves both into one run of text; split shows the old line above the new one. |
...rest | Omit<ComponentProps<"span">, "children"> | none | Every native span attribute, including id and lang. |
Anatomy
| root | .eq-text-diff | A span, so it fits inside a paragraph or list item; a grid in split mode. |
|---|---|---|
| removed | .eq-text-diff-removed | A del element: tinted and struck through. |
| added | .eq-text-diff-added | An ins element: tinted and set in the strong weight. |
| label | .eq-text-diff-label | Visually hidden words that tell screen readers what each change is. |
| line | .eq-text-diff-line | Split mode only: one row for the old text, one for the new. |
| sign | .eq-text-diff-sign | Split mode only: a minus or plus in the gutter, hidden from assistive tech. |
| text | .eq-text-diff-text | Split mode only: the content of a line. |
Notes
- Changes are del and ins elements with hidden removed and added prefixes, so the change reads sensibly where a screen reader does not announce those elements.
- Colour is never the only cue: removed text is struck through and added text is set in the strong weight.
- The root carries no aria-label, because a name on a span without a role is not valid ARIA; the inline semantics carry the meaning.
- Copying a diff includes the hidden prefixes, which reads as a plain text record of the change.
- Whitespace caught between two changes joins them, so a phrase reads as one replacement rather than word by word.
- The diff runs on the server and ships no JavaScript; above 250,000 compared token pairs it shows the whole old text removed and the whole new text added.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/text-diff.tsx188 lines
import type { ComponentProps } from "react";
import "./text-diff.css";
export type TextDiffProps = Omit<ComponentProps<"span">, "children"> & {
/** The old text. */
before: string;
/** The new text. */
after: string;
/** Compare whole words (with punctuation on its own) or single characters. */
granularity?: "word" | "char";
/** Inline weaves both into one line; split shows the old line above the new one. */
mode?: "inline" | "split";
};
type Kind = "same" | "removed" | "added";
type Part = { kind: Kind; text: string };
/* Past this many table cells the diff gives up and shows the whole old text replaced. */
const LIMIT = 250_000;
/* Runs of letters and digits, runs of whitespace, and each punctuation mark on its own. */
const WORD = /\s+|[\p{L}\p{M}\p{N}_]+|[^\s\p{L}\p{M}\p{N}_]/gu;
function tokenize(text: string, granularity: "word" | "char") {
return granularity === "char" ? Array.from(text) : (text.match(WORD) ?? []);
}
function push(parts: Part[], kind: Kind, text: string) {
if (text === "") return;
const last = parts[parts.length - 1];
if (last && last.kind === kind) last.text += text;
else parts.push({ kind, text });
}
/** Longest common subsequence over tokens, after trimming the shared start and end. */
function compare(a: string[], b: string[]) {
const parts: Part[] = [];
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--;
}
push(parts, "same", a.slice(0, start).join(""));
const x = a.slice(start, endA);
const y = b.slice(start, endB);
const n = x.length;
const m = y.length;
if (n * m > LIMIT) {
push(parts, "removed", x.join(""));
push(parts, "added", y.join(""));
} else {
// table[i][j] is the LCS length of x[i..] and y[j..], so the walk below can go forwards.
const width = m + 1;
const table = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
table[i * width + j] =
x[i] === y[j]
? table[(i + 1) * width + j + 1] + 1
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
}
}
let i = 0;
let j = 0;
while (i < n && j < m) {
if (x[i] === y[j]) {
push(parts, "same", x[i]);
i++;
j++;
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
push(parts, "removed", x[i]);
i++;
} else {
push(parts, "added", y[j]);
j++;
}
}
for (; i < n; i++) push(parts, "removed", x[i]);
for (; j < m; j++) push(parts, "added", y[j]);
}
push(parts, "same", a.slice(endA).join(""));
return tidy(parts);
}
/**
* Reads like a person wrote it: whitespace caught between two changes joins them, and each
* changed stretch shows all of its removed text before all of its added text.
*/
function tidy(parts: Part[]) {
const out: Part[] = [];
let removed = "";
let added = "";
const flush = () => {
push(out, "removed", removed);
push(out, "added", added);
removed = "";
added = "";
};
parts.forEach((part, index) => {
if (part.kind === "removed") removed += part.text;
else if (part.kind === "added") added += part.text;
else {
const bridging =
/^\s+$/.test(part.text) &&
index > 0 &&
index < parts.length - 1 &&
parts[index + 1].kind !== "same";
if (bridging && (removed || added)) {
removed += part.text;
added += part.text;
} else {
flush();
push(out, "same", part.text);
}
}
});
flush();
return out;
}
/** The hidden prefix gets a leading space when the text before it does not end in one. */
function label(word: string, previous: string) {
return `${previous === "" || /\s$/.test(previous) ? "" : " "}${word}: `;
}
function Change({ part, previous }: { part: Part; previous: string }) {
if (part.kind === "same") return <>{part.text}</>;
const Tag = part.kind === "removed" ? "del" : "ins";
return (
<Tag className={`eq-text-diff-${part.kind}`}>
<span className="eq-text-diff-label">{label(part.kind, previous)}</span>
{part.text}
</Tag>
);
}
function Run({ parts }: { parts: Part[] }) {
return parts.map((part, index) => (
<Change key={index} part={part} previous={index > 0 ? parts[index - 1].text : ""} />
));
}
export function TextDiff({
before,
after,
granularity = "word",
mode = "inline",
className,
...rest
}: TextDiffProps) {
const parts = compare(tokenize(before, granularity), tokenize(after, granularity));
const root = {
...rest,
className: ["eq-text-diff", className].filter(Boolean).join(" "),
"data-granularity": granularity,
"data-mode": mode,
};
if (mode === "inline") {
return (
<span {...root}>
<Run parts={parts} />
</span>
);
}
return (
<span {...root}>
{(["removed", "added"] as const).map((side) => (
<span key={side} className="eq-text-diff-line" data-side={side === "removed" ? "before" : "after"}>
<span className="eq-text-diff-sign" aria-hidden="true">
{side === "removed" ? "−" : "+"}
</span>
<span className="eq-text-diff-label">{side === "removed" ? "Before: " : "After: "}</span>
<span className="eq-text-diff-text">
<Run parts={parts.filter((part) => part.kind === "same" || part.kind === side)} />
</span>
</span>
))}
</span>
);
}components/equip/text-diff.css72 lines
@layer equip {
/* A long address or identifier breaks instead of running out of its container. */
.eq-text-diff {
overflow-wrap: anywhere;
}
.eq-text-diff[data-mode="split"] {
display: grid;
gap: var(--eq-space-1);
}
.eq-text-diff-line {
display: grid;
grid-template-columns: 1ch minmax(0, 1fr);
column-gap: var(--eq-space-2);
align-items: baseline;
}
.eq-text-diff-sign {
color: var(--eq-ink-3);
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
text-align: center;
user-select: none;
}
.eq-text-diff-text {
min-width: 0;
}
.eq-text-diff-removed,
.eq-text-diff-added {
padding-inline: calc(var(--eq-space-1) / 2);
border-radius: var(--eq-radius-s);
color: inherit;
/* Each wrapped line keeps its own padding and rounded ends. */
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
/* Character changes sit inside a word, so they get no padding that would split it. */
.eq-text-diff[data-granularity="char"] :is(.eq-text-diff-removed, .eq-text-diff-added) {
padding-inline: 0;
}
/* Never assume the surface is opaque: the tint mixes with transparent, not with a surface. */
.eq-text-diff-removed {
background: color-mix(in srgb, var(--eq-danger) 15%, transparent);
text-decoration: line-through;
text-decoration-color: var(--eq-danger);
text-decoration-thickness: 1px;
}
/* Weight, not colour alone, marks added text; the native underline is dropped. */
.eq-text-diff-added {
background: color-mix(in srgb, var(--eq-ok) 17%, transparent);
text-decoration: none;
font-weight: var(--eq-weight-strong);
}
.eq-text-diff-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: pre;
border: 0;
}
}