A line-by-line text diff with change counts, line numbers, and accessible change labels.
export const settings = {Removed: theme: "light",Removed: notifications: false,Added: theme: "system",Added: notifications: true, autoSave: true,}import { DiffViewer } from "@/components/ballmac/diff-viewer"
const before = `export const settings = {
theme: "light",
notifications: false,
autoSave: true,
}`
const after = `export const settings = {
theme: "system",
notifications: true,
autoSave: true,
}`
export default function DiffViewerDemo() {
return (
<DiffViewer
className="w-full max-w-md"
label="settings.ts"
before={before}
after={after}
/>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/diff-viewerAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/diff-viewer.tsx// Ballmac UI: Diff Viewer. https://ui.ballmac.com/components/diff-viewer "use client" import * as React from "react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type DiffViewerProps = React.ComponentProps<"div"> & { /** Original text. */ before: string /** Updated text. */ after: string /** Accessible name, usually a filename. */ label: string /** Show line numbers in the gutter. */ lineNumbers?: boolean } type DiffLine = { kind: "same" | "added" | "removed" text: string before?: number after?: number } function compare(before: string, after: string): DiffLine[] { const oldLines = before.split("\n") const newLines = after.split("\n") const table = Array.from( { length: oldLines.length + 1 }, () => new Uint32Array(newLines.length + 1), ) for (let i = oldLines.length - 1; i >= 0; i--) for (let j = newLines.length - 1; j >= 0; j--) table[i][j] = oldLines[i] === newLines[j] ? 1 + table[i + 1][j + 1] : Math.max(table[i + 1][j], table[i][j + 1]) const result: DiffLine[] = [] let i = 0, j = 0 while (i < oldLines.length || j < newLines.length) { if ( i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j] ) { result.push({ kind: "same", text: oldLines[i], before: ++i, after: ++j }) continue } if ( i < oldLines.length && (j >= newLines.length || table[i + 1][j] >= table[i][j + 1]) ) { result.push({ kind: "removed", text: oldLines[i], before: ++i }) continue } result.push({ kind: "added", text: newLines[j], after: ++j }) } return result } function DiffViewer({ className, before, after, label, lineNumbers = true, ...props }: DiffViewerProps) { const msg = useMessages() const lines = compare(before, after) const added = lines.filter((line) => line.kind === "added").length const removed = lines.filter((line) => line.kind === "removed").length return ( <div data-slot="diff-viewer" dir="ltr" role="region" aria-label={msg("diff-viewer.diff", "{label} diff", { label })} tabIndex={0} className={cn( "bg-card min-w-0 max-w-full overflow-auto rounded-xl border border-border outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className, )} {...props} > <div data-slot="diff-viewer-header" className="flex min-w-max items-center justify-between gap-4 border-b border-border px-4 py-2 text-xs" > <span className="truncate font-mono font-medium">{label}</span> <span className="text-muted-foreground shrink-0 tabular-nums"> {msg("diff-viewer.summary", "{added} added · {removed} removed", { added, removed })} </span> </div> <div className="min-w-max py-2 font-mono text-xs leading-6"> {lines.map((line, index) => ( <div key={index} data-slot="diff-viewer-line" data-kind={line.kind} className={cn( "relative flex min-h-6 pe-4", line.kind === "added" && "bg-primary/10", line.kind === "removed" && "bg-destructive/10", )} > <span aria-hidden="true" className="text-muted-foreground w-10 shrink-0 select-none pe-2 text-end tabular-nums" > {lineNumbers ? (line.after ?? line.before) : ""} </span> <span aria-hidden="true" className="text-muted-foreground w-5 shrink-0 select-none text-center" > {line.kind === "added" ? "+" : line.kind === "removed" ? "−" : " "} </span> <code className="whitespace-pre"> {line.kind !== "same" && ( <span className="sr-only select-none"> {line.kind === "added" ? "Added" : "Removed"}:{" "} </span> )} {line.text || " "} </code> </div> ))} </div> </div> ) } export { DiffViewer, type DiffViewerProps }Update the import paths to match your project setup.
Usage
import { DiffViewer } from "@/components/ballmac/diff-viewer"The full example is in the Code tab above.
Examples
States and variants
## Changes- Fixed searchAdded: - Improved keyboard navigation- Updated docsimport { DiffViewer } from "@/components/ballmac/diff-viewer"
export default function DiffViewerStates() {
return (
<DiffViewer
className="w-full max-w-sm"
label="release-notes.md"
before={`## Changes
- Fixed search
- Updated docs`}
after={`## Changes
- Fixed search
- Improved keyboard navigation
- Updated docs`}
lineNumbers={false}
/>
)
}API reference
| Prop | Type | Default |
|---|---|---|
before*Original text. | string | — |
after*Updated text. | string | — |
label*Accessible name, usually a filename. | string | — |
lineNumbersShow line numbers in the gutter. | boolean | true |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab / Arrow keys | Focuses and scrolls long diffs |
Use with AI
A line-by-line text diff with change counts, line numbers, and accessible change labels. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Diff Viewer (@ballmac/diff-viewer) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Review before-and-after text changes
Not for
- Use Comparison Table for product features
Registry JSON: https://ui.ballmac.com/r/diff-viewer.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Code Block
A code panel with filename and language header, line numbers, highlighted lines, a wrap toggle, copy feedback and file tabs. No highlighter bundled; pass Shiki output as children.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Audio Player
A podcast-style audio player: play and pause, skip back and forward, a scrubber with chapter ticks, a named current chapter and a speed control. Works with a real file or as a silent demo.
Avatar Circles
Overlapping avatars that spread on hover, lift individually with a name and role tooltip, show presence, fall back to initials and end in a +N count that can be a button.