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: 1 added: 2
Instance type
m6i. added: xlarge
Image tag
api:2024.1 removed: 1 added: 2. removed: 3 added: 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: removed: Email the on-call engineer after removed: 10 minutes without acknowledgement.After: added: Page the on-call engineer added: and the team lead after added: 5 minutes without acknowledgement.
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:02removed: finance added: accounts-payable@example.com
  • Tomás Ferreira changed the workspace name at 09:47Larder removed: Platform added: 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

PropTypeDefaultWhat it does
beforestringnoneThe old text.
afterstringnoneThe 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.
...restOmit<ComponentProps<"span">, "children">noneEvery native span attribute, including id and lang.

Anatomy

root.eq-text-diffA span, so it fits inside a paragraph or list item; a grid in split mode.
removed.eq-text-diff-removedA del element: tinted and struck through.
added.eq-text-diff-addedAn ins element: tinted and set in the strong weight.
label.eq-text-diff-labelVisually hidden words that tell screen readers what each change is.
line.eq-text-diff-lineSplit mode only: one row for the old text, one for the new.
sign.eq-text-diff-signSplit mode only: a minus or plus in the gutter, hidden from assistive tech.
text.eq-text-diff-textSplit 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index