Two versions of a prompt compared word by word: removed words struck through and added words underlined so colour is never the only cue, a count of each, {{variables}} marked, side by side or inline, and an optional Restore button.
+17 words·−1 words
v12 · 3 Oct
You are a support assistant for {{company}}. Answer questions about orders and refunds. Be friendly and keep answers [removed: short]. If you do not know the answer, say so. Never promise a refund.
v13 · today
You are a support assistant for {{company}}. Answer questions about orders[added: ,] [added: shipping ]and refunds. Be friendly and keep answers [added: under {{max_words}} words]. If you do not know the answer, say so[added: and offer to pass the conversation to a person]. Never promise a refund[added: before checking the policy].
import { PromptDiff } from "@/components/ballmac/prompt-diff"
const before = `You are a support assistant for {{company}}. Answer questions about orders and refunds. Be friendly and keep answers short. If you do not know the answer, say so. Never promise a refund.`
const after = `You are a support assistant for {{company}}. Answer questions about orders, shipping and refunds. Be friendly and keep answers under {{max_words}} words. If you do not know the answer, say so and offer to pass the conversation to a person. Never promise a refund before checking the policy.`
export default function PromptDiffDemo() {
return (
<div className="w-full max-w-4xl">
<PromptDiff before={before} after={after} beforeLabel="v12 · 3 Oct" afterLabel="v13 · today" />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/prompt-diffInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/prompt-diff.tsx// Ballmac UI: Prompt Diff. https://ui.ballmac.com/components/prompt-diff "use client" import * as React from "react" import { History, Rows2, Columns2 } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type DiffPart = { type: "same" | "add" | "del"; text: string } /** Splits text into words, {{variables}}, punctuation marks and the spaces between them, so spacing survives and a full stop never makes a word look changed. */ const tokenize = (s: string) => s.match(/\s+|\{\{[^}]*\}\}|[\p{L}\p{N}_'’-]+|[^\s\p{L}\p{N}]/gu) ?? [] /** * A word-by-word comparison of two texts using the longest common subsequence. Each part is kept, added or deleted. Very large inputs (over 200,000 word pairs) * fall back to "everything deleted, everything added" rather than freezing the page. */ function diffWords(before: string, after: string): DiffPart[] { const a = tokenize(before) const b = tokenize(after) if (a.length * b.length > 200_000) return [...(before ? [{ type: "del" as const, text: before }] : []), ...(after ? [{ type: "add" as const, text: after }] : [])] const n = a.length const m = b.length const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0)) for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]) const out: DiffPart[] = [] const push = (type: DiffPart["type"], text: string) => { const last = out[out.length - 1] if (last && last.type === type) last.text += text else out.push({ type, text }) } let i = 0 let j = 0 while (i < n && j < m) { if (a[i] === b[j]) (push("same", a[i]), i++, j++) else if (lcs[i + 1][j] >= lcs[i][j + 1]) push("del", a[i++]) else push("add", b[j++]) } while (i < n) push("del", a[i++]) while (j < m) push("add", b[j++]) return out } /** How many words (and variables) a text holds: runs of letters or digits, and {{variables}}. */ const words = (s: string) => s.match(/\{\{[^}]*\}\}|[\p{L}\p{N}_'’-]+/gu)?.length ?? 0 /** Wraps {{variables}} in a quiet chip so they stand out from the prose. */ function Text({ text }: { text: string }) { return ( <> {text.split(/(\{\{[^}]+\}\})/g).map((p, i) => p.startsWith("{{") ? ( <code key={i} className="rounded bg-muted px-1 py-px font-mono text-[0.85em]" dir="ltr">{p}</code> ) : ( <React.Fragment key={i}>{p}</React.Fragment> ) )} </> ) } type PromptDiffProps = Omit<React.ComponentProps<"section">, "title"> & { /** The earlier version. */ before: string /** The later version. */ after: string /** Name of the earlier version, for example "v12, 3 Oct". */ beforeLabel?: string /** Name of the later version. */ afterLabel?: string /** Layout: side by side, or one text with changes marked inline. */ mode?: "split" | "unified" /** Called when the person asks to go back to the earlier version. Adds the button. */ onRestore?: () => void } /** * Two versions of a prompt compared word by word. Removed words are struck through and added words are underlined, so the difference never depends on colour; * a summary counts the words each way. {{variables}} are marked so a change to one is easy to see. Side by side or inline, and an optional Restore button. */ function PromptDiff({ before, after, beforeLabel, afterLabel, mode: modeProp = "split", onRestore, className, ...props }: PromptDiffProps) { const msg = useMessages() const [mode, setMode] = React.useState(modeProp) beforeLabel ??= msg("prompt-diff.before", "Before") afterLabel ??= msg("prompt-diff.after", "After") const parts = React.useMemo(() => diffWords(before, after), [before, after]) const added = parts.filter((p) => p.type === "add").reduce((n, p) => n + words(p.text), 0) const removed = parts.filter((p) => p.type === "del").reduce((n, p) => n + words(p.text), 0) const same = before === after const del = (t: string, k: number) => ( <del key={k} className="rounded-sm bg-destructive/15 text-foreground line-through decoration-destructive decoration-1"> <span className="sr-only">{msg("prompt-diff.removedMark", "[removed:")} </span> <Text text={t} /> <span className="sr-only">]</span> </del> ) const ins = (t: string, k: number) => ( <ins key={k} className="rounded-sm bg-chart-2/20 text-foreground underline decoration-chart-2 decoration-2 underline-offset-2"> <span className="sr-only">{msg("prompt-diff.addedMark", "[added:")} </span> <Text text={t} /> <span className="sr-only">]</span> </ins> ) return ( <section data-slot="prompt-diff" className={cn("grid w-full min-w-0 gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5", className)} {...props}> <div className="flex flex-wrap items-center justify-between gap-3"> <p aria-live="polite" className="text-sm tabular-nums"> {same ? <span className="text-muted-foreground">{msg("prompt-diff.same", "The two versions are the same.")}</span> : ( <> <span className="font-medium">{msg("prompt-diff.added", "+{count} words", { count: added })}</span> <span className="mx-2 text-muted-foreground">·</span> <span className="font-medium">{msg("prompt-diff.removed", "−{count} words", { count: removed })}</span> </> )} </p> <div className="flex items-center gap-2"> <div role="group" aria-label={msg("prompt-diff.view", "View")} className="inline-flex rounded-full border p-0.5"> {([["split", Columns2, msg("prompt-diff.split", "Side by side")], ["unified", Rows2, msg("prompt-diff.unified", "Inline")]] as const).map(([k, Icon, l]) => ( <button key={k} type="button" aria-pressed={mode === k} onClick={() => setMode(k)} className={cn("inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", mode === k ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground")}> <Icon aria-hidden="true" className="size-3.5" /> {l} </button> ))} </div> {onRestore && ( <button type="button" onClick={onRestore} className="inline-flex h-8 items-center gap-1.5 rounded-full border bg-background px-3 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none"> <History aria-hidden="true" className="size-3.5" /> {msg("prompt-diff.restore", "Restore {label}", { label: beforeLabel })} </button> )} </div> </div> {mode === "split" ? ( <div className="grid gap-3 md:grid-cols-2"> {([[beforeLabel, "del"], [afterLabel, "add"]] as const).map(([label, side]) => ( <div key={label} className="min-w-0 rounded-lg border"> <p className="border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">{label}</p> <p className="whitespace-pre-wrap p-3 text-[13px] leading-relaxed [overflow-wrap:anywhere]" dir="auto"> {parts.map((p, i) => (p.type === "same" ? <Text key={i} text={p.text} /> : p.type === side ? (side === "del" ? del(p.text, i) : ins(p.text, i)) : null))} </p> </div> ))} </div> ) : ( <div className="min-w-0 rounded-lg border"> <p className="border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">{beforeLabel} → {afterLabel}</p> <p className="whitespace-pre-wrap p-3 text-[13px] leading-relaxed [overflow-wrap:anywhere]" dir="auto"> {parts.map((p, i) => (p.type === "same" ? <Text key={i} text={p.text} /> : p.type === "del" ? del(p.text, i) : ins(p.text, i)))} </p> </div> )} </section> ) } export { PromptDiff, diffWords, type DiffPart, type PromptDiffProps }Update the import paths to match your project setup.
Usage
import { PromptDiff, diffWords } from "@/components/ballmac/prompt-diff"The full example is in the Code tab above.
Examples
Inline, with restore
+8 words·−7 words
v7 → v8
Summarise the ticket in [removed: two][added: one] [removed: sentences][added: sentence]. Include the [removed: customer's][added: order] [removed: name][added: number.] [removed: and][added: Leave] [added: out ]the [removed: order][added: customer's] [removed: number][added: name].
"use client"
import * as React from "react"
import { PromptDiff } from "@/components/ballmac/prompt-diff"
export default function PromptDiffInline() {
const [restored, setRestored] = React.useState(false)
return (
<div className="grid w-full max-w-3xl gap-3">
<PromptDiff
mode="unified"
beforeLabel="v7"
afterLabel="v8"
before={"Summarise the ticket in two sentences. Include the customer's name and the order number."}
after={"Summarise the ticket in one sentence. Include the order number. Leave out the customer's name."}
onRestore={() => setRestored(true)}
/>
{restored && <p role="status" className="rounded-lg border bg-card px-4 py-3 text-sm">Restored v7. The assistant will use it from the next conversation.</p>}
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
before*The earlier version. | string | — |
after*The later version. | string | — |
beforeLabelName of the earlier version, for example "v12, 3 Oct". | string | — |
afterLabelName of the later version. | string | — |
modeLayout: side by side, or one text with changes marked inline. | "split" | "unified" | — |
onRestoreCalled when the person asks to go back to the earlier version. Adds the button. | () => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | Changes are read as '[removed: old words]' and '[added: new words]'; the counts are a polite live region |
| Colour | Removed words are struck through and added words underlined, so the difference never depends on colour |
| Tab, Enter / Space | The view switch is two toggle buttons (aria-pressed); Restore is a real button |
Use with AI
before and after are the two texts; beforeLabel / afterLabel name them; mode is 'split' (default) or 'unified'; onRestore adds a 'Restore <before label>' button. Exports diffWords(before, after) returning [{ type: 'same' | 'add' | 'del', text }]. Compares words and keeps spacing; inputs over 200,000 word pairs are shown as fully replaced. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Prompt Diff (@ballmac/prompt-diff) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Reviewing what changed between two prompt versions before shipping or rolling back
- Any short text where the exact words changed matter
Not for
- Code or files; use diff-viewer
- Comparing model outputs side by side; use parallel-attempts
Registry JSON: https://ui.ballmac.com/r/prompt-diff.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Parallel Attempts
Compare several runs of the same task side by side: status, summary, a changed-lines bar, time and cost on each card, a suggested run, and a native radio group to pick one and continue.
Run Ledger
What an agent run cost, step by step: sortable time, tokens and money with each row's share, a total, the most expensive step marked, and an optional budget meter that warns in words.
Heat Grid
A matrix of values shaded by size, such as models against test cases or services against regions: a real ARIA grid with arrow-key navigation, value text in every cell, a tone mode with icons and words, hatched empty cells and a legend.
Diff Viewer
A line-by-line text diff with change counts, line numbers, and accessible change labels.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.