LAB/ Tanishk Interface Lab
SystemsstableReactLCS word diff

Inline Diff Text

Original, revision and reason — revealed in place, legibly.

Live preview

Editor

Onboarding — one-pager

Version history

Current draft

Onboarding needs a shorter path to first success.

Version history

  1. Rev 2Cut the hedging

    recorded

    removed: We added: Onboarding should removed: probably try to improve removed: the onboarding soon.

  2. Rev 3Named the outcome

    recorded

    Onboarding removed: should improve soon. added: needs a shorter path to first success.

  3. Rev 4Said what success is

    awaiting review

    Onboarding needs a shorter path to added: the first removed: success. added: saved project.

RevisionShow reasonEmphasis

Toggle the reason, or accept the revision

01What it does

A sentence that changed, shown as a sentence: the removed words stay visible, the revision sits in their place, and the reason is one affordance away. Word-level diffing is computed locally, so it works for editorial tools, version history and any AI rewrite that owes the reader an explanation.

In this previewThree prose revisions with word-level changes. Open the reason, or accept the revision to see the sentence settle.

02Install

Registry install

Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/inline-diff/ with its stylesheet beside it.

npx shadcn@latest add <lab-origin>/r/inline-diff.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/inline-diff/inline-diff.tsxcomponent
  • components/lab/inline-diff/word-diff.tslogic
  • components/lab/inline-diff/inline-diff.cssstyles

Machine-readable item: /r/inline-diff.json ↗

03Usage

inline-diff.usage.tsx
import { InlineDiffText } from "@/components/lab/inline-diff/inline-diff";

<InlineDiffText
change={{
id: "d1",
before: "We should probably try to improve the onboarding soon.",
after: "Onboarding needs a shorter path to the first success.",
reason: "Tightened the claim and named the outcome.",
author: "editor",
at: "14:02",
}}
onAccept={accept}
/>

04Props

Props accepted by Inline Diff Text
PropTypeDefaultNotes
changeInlineDiffChange—{ id, before, after, reason, author?, at?, segments? }
segmentsDiffSegment[]—Pre-computed word segments; omit to diff before/after with the bundled LCS.
defaultOpenbooleanfalseShow the reason on first render.
onAccept() => void—When provided, an accept control appears next to the reason.
emphasis"quiet" | "marked""marked"How strongly the change is tinted.

05Accessibility

  • Removed and added words are announced as text, prefixed with “removed:” / “added:” in a screen-reader-only layer — never colour alone.
  • The reason is a button with aria-expanded; accepting is a normal button with a status announcement.
  • The diff is computed with a bounded LCS on word tokens, so long paragraphs stay fast.
  • Works in both directions: nothing is lost if the revision is rejected — the original remains readable in place.

06Source

3 files, 547 lines. Nothing private, no configuration required.

components/lab/inline-diff/inline-diff.tsx— press enter to show or hide the source
tsx
"use client";

import { useId, useMemo, useState, type ReactNode } from "react";
import { diffWords, type DiffSegment } from "./word-diff";
import "./inline-diff.css";

export type InlineDiffChange = {
id: string;
before: string;
after: string;
reason: string;
author?: string;
at?: string;
segments?: DiffSegment[];
};

type InlineDiffTextProps = {
change: InlineDiffChange;
/**
* Pre-computed word segments. Takes precedence over `change.segments`
* and over diffing `before` / `after` with the bundled LCS.
*/
segments?: DiffSegment[];
/** Show the reason on first render. */
defaultOpen?: boolean;
/** When provided, an accept control appears next to the reason. */
onAccept?: () => void;
/** How strongly the change is tinted. */
emphasis?: "quiet" | "marked";
className?: string;
};

/**
* INLINE DIFF TEXT — a sentence that changed, shown as a sentence.
*
* The removed words stay in place with a strikethrough and a muted
* wash, the revision sits beside them, and the reason is one button
* away. Removed and added runs carry screen-reader-only "removed:" /
* "added:" prefixes, so the change is never carried by colour alone.
*
* Accepting is the host's call: pressing Accept reports through
* onAccept and settles the local view on the revision — the reason
* is kept, not discarded.
*/
export function InlineDiffText({
change,
segments,
defaultOpen = false,
onAccept,
emphasis = "marked",
className,
}: InlineDiffTextProps) {
const [reasonOpen, setReasonOpen] = useState(defaultOpen);
const [lastDefaultOpen, setLastDefaultOpen] = useState(defaultOpen);
const [accepted, setAccepted] = useState(false);
const panelId = useId();

// Uncontrolled, but a host that changes defaultOpen (a preview
// control, a reset to defaults) should see the reason follow.
if (defaultOpen !== lastDefaultOpen) {
setLastDefaultOpen(defaultOpen);
setReasonOpen(defaultOpen);
}

const diff = useMemo(
() => segments ?? change.segments ?? diffWords(change.before, change.after),
[segments, change.segments, change.before, change.after],
);

const accept = () => {
setAccepted(true);
onAccept?.();
};

return (
<section
className={`id-root${className ? ` ${className}` : ""}`}
data-emphasis={emphasis}
data-settled={accepted}
data-reason-open={reasonOpen}
>
<p className="id-sentence">
{accepted ? change.after : renderSegments(diff)}
</p>

<div className="id-row">
{accepted ? (
<span className="id-accepted">
<svg
viewBox="0 0 16 16"
width="11"
height="11"
aria-hidden="true"
focusable="false"
>
<path
d="M3 8.5 6.4 12 13 4.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Accepted · reason kept
</span>
) : null}

<button
type="button"
className="id-reason"
aria-expanded={reasonOpen}
aria-controls={panelId}
onClick={() => setReasonOpen((current) => !current)}
>
Reason
<svg
className="id-chevron"
viewBox="0 0 16 16"
width="11"
height="11"
aria-hidden="true"
focusable="false"
>
<path
d="M4 6.25 8 10.25 12 6.25"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>

{onAccept && !accepted ? (
<button type="button" className="id-accept" onClick={accept}>
Accept
</button>
) : null}
</div>

<div
className="id-panel"
id={panelId}
data-open={reasonOpen}
aria-hidden={!reasonOpen}
inert={!reasonOpen}
>
<div className="id-panel-inner">
<p className="id-reason-text">{change.reason}</p>
{change.author || change.at ? (
<p className="id-byline">
{change.author ? <span>{change.author}</span> : null}
{change.author && change.at ? (
<span aria-hidden="true">·</span>
) : null}
{change.at ? <span title={change.at}>{change.at}</span> : null}
</p>
) : null}
</div>
</div>

<p className="id-sr" role="status">
{accepted ? "Revision accepted." : ""}
</p>
</section>
);
}

/**
* Removed and added runs are announced with their kind in a hidden
* prefix. Tokens keep their trailing whitespace, so a final token
* without one gets a shown space to stay legible against its
* neighbour.
*/
function renderSegments(segments: DiffSegment[]): ReactNode {
return segments.map((segment, index) => {
if (segment.kind === "same") {
return <span key={index}>{segment.text}</span>;
}

const gap = /\s$/.test(segment.text) ? "" : " ";

if (segment.kind === "removed") {
return (
<del className="id-removed" key={index}>
<span className="id-sr">{"removed: "}</span>
{segment.text}
{gap}
</del>
);
}

return (
<ins className="id-added" key={index}>
<span className="id-sr">{"added: "}</span>
{segment.text}
{gap}
</ins>
);
});
}
components/lab/inline-diff/word-diff.ts— press enter to show or hide the source
ts
/**
* WORD DIFF — pure, dependency-free token diffing.
*
* Both strings are tokenised into words with their trailing
* whitespace attached, so joining a run of tokens reconstructs the
* original text exactly. A standard LCS dynamic program then walks the
* two token arrays backwards and emits same / removed / added
* segments in order.
*
* The work is bounded: past 400 tokens per side the diff degrades to
* one removed and one added block instead of a quadratic table.
*/

export type DiffSegment = {
kind: "same" | "removed" | "added";
text: string;
};

/** Above this many tokens per side, fall back to a whole-string swap. */
const MAX_TOKENS = 400;

/** A word (with any trailing whitespace) or a run of whitespace. */
const TOKEN_PATTERN = /\s+|\S+\s*/g;

function tokenise(input: string): string[] {
return input.match(TOKEN_PATTERN) ?? [];
}

export function diffWords(before: string, after: string): DiffSegment[] {
if (before === after) {
return before.length > 0 ? [{ kind: "same", text: before }] : [];
}

const a = tokenise(before);
const b = tokenise(after);

if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) {
return [
{ kind: "removed", text: before },
{ kind: "added", text: after },
];
}

const width = b.length + 1;
const table = new Uint32Array((a.length + 1) * width);

for (let i = 1; i <= a.length; i += 1) {
for (let j = 1; j <= b.length; j += 1) {
table[i * width + j] =
a[i - 1] === b[j - 1]
? table[(i - 1) * width + (j - 1)] + 1
: Math.max(
table[(i - 1) * width + j],
table[i * width + (j - 1)],
);
}
}

// Walk the table from the end; the ops come out reversed.
const ops: DiffSegment[] = [];
let i = a.length;
let j = b.length;

while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) {
ops.push({ kind: "same", text: a[i - 1] });
i -= 1;
j -= 1;
} else if (table[(i - 1) * width + j] >= table[i * width + (j - 1)]) {
ops.push({ kind: "removed", text: a[i - 1] });
i -= 1;
} else {
ops.push({ kind: "added", text: b[j - 1] });
j -= 1;
}
}

while (i > 0) {
i -= 1;
ops.push({ kind: "removed", text: a[i] });
}

while (j > 0) {
j -= 1;
ops.push({ kind: "added", text: b[j] });
}

ops.reverse();
return groupChanges(ops);
}

/**
* Inside one change region the walk can interleave removals and
* additions. Rendering reads better with the removals first, and
* neither string is affected: only the order of tokens within a
* region changes, never their membership or their order within a
* kind. Consecutive ops of the same kind are merged here too.
*/
function groupChanges(ops: DiffSegment[]): DiffSegment[] {
const grouped: DiffSegment[] = [];
let index = 0;

while (index < ops.length) {
if (ops[index].kind === "same") {
const previous = grouped[grouped.length - 1];
if (previous && previous.kind === "same") previous.text += ops[index].text;
else grouped.push({ kind: "same", text: ops[index].text });
index += 1;
continue;
}

let removed = "";
let added = "";
while (index < ops.length && ops[index].kind !== "same") {
if (ops[index].kind === "removed") removed += ops[index].text;
else added += ops[index].text;
index += 1;
}

if (removed.length > 0) grouped.push({ kind: "removed", text: removed });
if (added.length > 0) grouped.push({ kind: "added", text: added });
}

return grouped;
}
components/lab/inline-diff/inline-diff.css— press enter to show or hide the source
css
/* ============================================================
INLINE DIFF TEXT — self-contained styles.

Every token is scoped to the root with a fallback, so the
component carries its own look into any codebase that has
never heard of this stylesheet.
============================================================ */

.id-root {
--id-ink: #141418;
--id-muted: #5f5f68;
--id-faint: #8c8c94;
--id-line: rgba(20, 20, 24, 0.1);
--id-line-soft: rgba(20, 20, 24, 0.06);
--id-remove: #b3261e;
--id-add: #137a4e;
--id-focus: #4f27e0;
--id-reading: Georgia, "Iowan Old Style", "Times New Roman", ui-serif, serif;
--id-mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
--id-ease: cubic-bezier(0.22, 1, 0.36, 1);

color: var(--id-ink);
font-size: 0.9375rem;
line-height: 1.6;
}

.id-root,
.id-root *,
.id-root *::before,
.id-root *::after {
box-sizing: border-box;
}

/* ---- the sentence ------------------------------------------ */

.id-sentence {
margin: 0;
font-family: var(--id-reading);
font-size: 1.0625rem;
line-height: 1.65;
letter-spacing: -0.003em;
overflow-wrap: anywhere;
}

.id-removed,
.id-added {
padding: 0 0.14em;
border-radius: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}

.id-removed {
background: rgba(179, 38, 30, 0.1);
color: inherit;
text-decoration: line-through;
text-decoration-color: rgba(179, 38, 30, 0.62);
text-decoration-thickness: 1px;
}

.id-added {
background: rgba(19, 122, 78, 0.13);
color: inherit;
text-decoration: none;
}

/* Quiet emphasis sits inside prose: marks, not washes. */
.id-root[data-emphasis="quiet"] .id-removed {
padding: 0;
background: transparent;
color: var(--id-muted);
text-decoration: line-through;
text-decoration-style: dotted;
text-decoration-color: rgba(179, 38, 30, 0.75);
}

.id-root[data-emphasis="quiet"] .id-added {
padding: 0;
background: transparent;
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: rgba(19, 122, 78, 0.8);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}

/* ---- the reason row ---------------------------------------- */

.id-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-top: 9px;
}

.id-reason,
.id-accept {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0;
padding: 3px 10px;
border: 1px solid var(--id-line);
border-radius: 999px;
background: #ffffff;
color: var(--id-muted);
font-family: var(--id-mono);
font-size: 0.6875rem;
letter-spacing: 0.03em;
cursor: pointer;
}

.id-reason:hover {
border-color: rgba(20, 20, 24, 0.22);
color: var(--id-ink);
}

.id-reason:focus-visible,
.id-accept:focus-visible {
outline: 2px solid var(--id-focus);
outline-offset: 2px;
}

.id-chevron {
transition: transform 180ms var(--id-ease);
}

.id-root[data-reason-open="true"] .id-chevron {
transform: rotate(180deg);
}

.id-accept {
border-color: var(--id-ink);
background: var(--id-ink);
color: #ffffff;
}

.id-accept:hover {
background: #000000;
}

.id-accepted {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--id-mono);
font-size: 0.6875rem;
letter-spacing: 0.03em;
color: var(--id-add);
}

/* ---- the revealed reason ----------------------------------- */

.id-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 220ms var(--id-ease);
}

.id-panel[data-open="true"] {
grid-template-rows: 1fr;
}

.id-panel-inner {
min-height: 0;
overflow: hidden;
}

.id-reason-text {
margin: 9px 0 0;
padding: 9px 12px;
border-left: 2px solid var(--id-line);
border-radius: 0 8px 8px 0;
background: var(--id-line-soft);
font-size: 0.8125rem;
line-height: 1.55;
color: var(--id-muted);
overflow-wrap: anywhere;
}

.id-byline {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
margin: 6px 0 0;
font-family: var(--id-mono);
font-size: 0.6875rem;
color: var(--id-faint);
}

.id-byline span[aria-hidden="true"] {
color: rgba(20, 20, 24, 0.25);
}

/* The component ships its own sr-only, so it never depends on the host. */
.id-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

@media (prefers-reduced-motion: reduce) {
.id-panel,
.id-chevron {
transition: none;
}
}