Diff Viewer
A code diff with unified and split views, +/− counts, collapsed unchanged lines you can expand, and per-line comments.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/diff-viewer.json'use client'
import { useMemo, useState } from 'react'
import './diff-viewer.css'
type Op = { k: 'eq' | 'add' | 'del'; a?: number; b?: number; t: string }
function diff(a: string[], b: string[]): Op[] {
const n = a.length, m = b.length
const L = 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--) L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1])
const out: Op[] = []
let i = 0, j = 0
while (i < n || j < m) {
if (i < n && j < m && a[i] === b[j]) { out.push({ k: 'eq', a: i + 1, b: j + 1, t: a[i] }); i++; j++ }
else if (i < n && (j >= m || L[i + 1][j] >= L[i][j + 1])) { out.push({ k: 'del', a: i + 1, t: a[i] }); i++ }
else { out.push({ k: 'add', b: j + 1, t: b[j] }); j++ }
}
return out
}
export interface DiffViewerProps {
before: string
after: string
file?: string
view?: 'unified' | 'split'
context?: number
fill?: 'dark' | 'light'
className?: string
}
/** Unified/split diff with folding and line comments. */
export function DiffViewer({ before, after, file = 'file.ts', view: v0 = 'unified', context = 2, fill = 'dark', className }: DiffViewerProps) {
const [view, setView] = useState(v0)
const [open, setOpen] = useState<number[]>([])
const [notes, setNotes] = useState<Record<number, string[]>>({})
const [draft, setDraft] = useState<{ at: number; text: string } | null>(null)
const ops = useMemo(() => diff(before.split('\n'), after.split('\n')), [before, after])
const adds = ops.filter((o) => o.k === 'add').length, dels = ops.filter((o) => o.k === 'del').length
const near = ops.map((_, i) => ops.slice(Math.max(0, i - context), i + context + 1).some((o) => o.k !== 'eq'))
type Block = { fold: true; from: number; to: number } | { fold: false; i: number }
const blocks: Block[] = []
for (let i = 0; i < ops.length; i++) {
if (near[i] || open.includes(i)) { blocks.push({ fold: false, i }); continue }
let j = i
while (j + 1 < ops.length && !near[j + 1] && !open.includes(j + 1)) j++
if (j - i < 2) for (let k = i; k <= j; k++) blocks.push({ fold: false, i: k })
else blocks.push({ fold: true, from: i, to: j })
i = j
}
const comment = (at: number) => setDraft({ at, text: '' })
const thread = (i: number) => (
<>
{(notes[i] ?? []).map((n, k) => <div key={k} className="sd-dif__cm"><b>You</b> · {n}</div>)}
{draft?.at === i && (
<div className="sd-dif__cm">
<b>Comment on line {ops[i].b ?? ops[i].a}</b>
<input autoFocus aria-label="Comment" placeholder="Leave a comment, Enter to save" value={draft.text} onChange={(e) => setDraft({ at: i, text: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter' && draft.text.trim()) { setNotes((n) => ({ ...n, [i]: [...(n[i] ?? []), draft.text.trim()] })); setDraft(null) } if (e.key === 'Escape') setDraft(null) }} />
</div>
)}
</>
)
const sym = (k: Op['k']) => (k === 'add' ? '+' : k === 'del' ? '−' : ' ')
const sr = (k: Op['k']) => (k === 'add' ? <span className="sr">added </span> : k === 'del' ? <span className="sr">removed </span> : null)
const line = (i: number) => {
const o = ops[i]
if (view === 'unified') return (
<div key={i}>
<div className="sd-dif__row">
<span className="sd-dif__n" data-k={o.k} role="button" tabIndex={-1} aria-label={`Comment on line ${o.b ?? o.a}`} onClick={() => comment(i)}>{o.b ?? o.a}</span>
<span className="sd-dif__m" data-k={o.k} aria-hidden>{sym(o.k)}</span>
<span className="sd-dif__c" data-k={o.k}>{sr(o.k)}{o.t || ' '}</span>
</div>
{thread(i)}
</div>
)
const L = o.k !== 'add', R = o.k !== 'del'
return (
<div key={i}>
<div className="sd-dif__row">
<span className="sd-dif__n" data-k={L ? o.k : undefined}>{L ? o.a : ''}</span>
<span className="sd-dif__m" data-k={L ? o.k : undefined} aria-hidden>{L ? sym(o.k) : ''}</span>
<span className="sd-dif__c" data-k={L ? o.k : undefined}>{L ? <>{sr(o.k)}{o.t || ' '}</> : ''}</span>
<span className="sd-dif__n" data-k={R ? o.k : undefined} role="button" tabIndex={-1} aria-label={`Comment on line ${o.b ?? o.a}`} onClick={() => comment(i)}>{R ? o.b : ''}</span>
<span className="sd-dif__m" data-k={R ? o.k : undefined} aria-hidden>{R ? sym(o.k) : ''}</span>
<span className="sd-dif__c" data-k={R ? o.k : undefined}>{R && o.k === 'add' ? <>{sr(o.k)}{o.t}</> : R ? o.t || ' ' : ''}</span>
</div>
{thread(i)}
</div>
)
}
return (
<div className={['sd-dif', className].filter(Boolean).join(' ')} data-fill={fill} data-view={view}>
<div className="sd-dif__h">
<span className="sd-dif__f">{file}</span>
<span className="sd-dif__st" aria-label={`${adds} additions, ${dels} deletions`}><b>+{adds}</b> −{dels}</span>
<div className="sd-dif__seg" role="radiogroup" aria-label="View">
{(['unified', 'split'] as const).map((x) => <button key={x} type="button" role="radio" aria-checked={view === x} onClick={() => setView(x)}>{x === 'unified' ? 'Unified' : 'Split'}</button>)}
</div>
</div>
<div className="sd-dif__body">
{blocks.map((b) => b.fold
? <button key={'f' + b.from} type="button" className="sd-dif__more" onClick={() => setOpen((o) => [...o, ...Array.from({ length: b.to - b.from + 1 }, (_, k) => b.from + k)])}>⋯ Show {b.to - b.from + 1} unchanged lines</button>
: line(b.i))}
</div>
</div>
)
}.sd-dif { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-dif[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-dif :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-dif :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-dif .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-dif { width: min(100%, 360px); border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; font-size: 12px; }
.sd-dif__h { display: flex; align-items: center; gap: 8px; padding: 9px 10px 9px 12px; border-bottom: 1px solid var(--line); }
.sd-dif__f { font: 600 12.5px/1 ui-monospace, monospace; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-dif__st { font: 600 11.5px/1 ui-monospace, monospace; color: var(--mute); }
.sd-dif__st b { color: var(--fg); font-weight: 600; }
.sd-dif__seg { margin-left: auto; display: flex; padding: 2px; border-radius: 8px; background: var(--soft); flex: none; }
.sd-dif__seg button { padding: 4px 8px; border-radius: 6px; font-size: 11.5px; color: var(--mute) !important; }
.sd-dif__seg button[aria-checked='true'] { background: var(--bg); color: var(--fg) !important; box-shadow: 0 1px 2px rgb(0 0 0 / 0.25); }
.sd-dif__body { max-height: 320px; overflow: auto; font: 11.5px/1.65 ui-monospace, SFMono-Regular, monospace; }
.sd-dif__row { display: grid; grid-template-columns: 30px 14px 1fr; min-width: max-content; }
.sd-dif[data-view='split'] .sd-dif__row { grid-template-columns: 26px 12px minmax(130px, 1fr) 26px 12px minmax(130px, 1fr); }
.sd-dif__n { padding-right: 6px; text-align: right; color: var(--mute); user-select: none; cursor: pointer; }
.sd-dif__n:hover { color: var(--fg); text-decoration: underline; }
.sd-dif__m { color: var(--mute); user-select: none; text-align: center; }
.sd-dif__c { padding-right: 12px; white-space: pre; }
.sd-dif [data-k='add'] { background: color-mix(in srgb, var(--fg) 9%, transparent); }
.sd-dif [data-k='add'].sd-dif__m { color: var(--fg); font-weight: 700; }
.sd-dif [data-k='del'] { background: color-mix(in srgb, var(--fg) 3%, transparent); color: var(--mute); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--mute) 60%, transparent); }
.sd-dif [data-k='del'].sd-dif__m { text-decoration: none; }
.sd-dif__more { display: block; width: 100%; padding: 4px 12px; text-align: left; background: var(--soft) !important; color: var(--mute) !important; font: inherit; }
.sd-dif__more:hover { color: var(--fg) !important; }
.sd-dif__cm { margin: 4px 10px 6px 44px; padding: 8px 10px; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line); font: 12.5px/1.4 ui-sans-serif, system-ui; white-space: normal; animation: sd-dif-in 200ms ease-out; }
.sd-dif__cm b { font-weight: 600; }
.sd-dif__cm input { width: 100%; margin-top: 6px; padding: 6px 8px; border: 0; border-radius: 7px; background: var(--soft); color: var(--fg); font: inherit; outline: 0; }
@keyframes sd-dif-in { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .sd-dif *, .sd-dif *::before, .sd-dif *::after { animation: none !important; transition: none !important; } }Overview
Switch between unified and side-by-side. Long runs of unchanged lines fold into a “Show N lines” row; expand them in place.
Hover a line number to add a comment; it threads under that line. Additions and removals are marked with symbols, not only tone.
When to use
- Code review, config history, document versions.
When not to use
- Prose with heavy rewording: a rendered compare reads better.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/diff-viewer.json$
npx shadcn@latest add https://sundaeui.dev/r/diff-viewer.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/diff-viewer.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/diff-viewer.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy diff-viewer.tsx and diff-viewer.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { DiffViewer } from '@/components/sundae/diff-viewer'
<DiffViewer />Props
| Prop | Type | Default |
|---|---|---|
before / after*Old and new text. | string | — |
fileFile name. | string | — |
viewStarting view. | 'unified' | 'split' | 'unified' |
contextUnchanged lines kept around changes. | number | 2 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each changed line says “added” or “removed” to screen readers.
- The view switch is a radiogroup; expand rows are buttons with the line count.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-dif; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Diff Viewer free?
- Yes. Diff Viewer is free for personal and commercial projects, with full source on this page.
- How do I install Diff Viewer?
- Run npx shadcn@latest add https://sundaeui.dev/r/diff-viewer.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Diff Viewer support keyboard and screen readers?
- Each changed line says “added” or “removed” to screen readers. The view switch is a radiogroup; expand rows are buttons with the line count.
- Does Diff Viewer respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Commit Graph
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined Countdown Timer
Data · Refined- Pro
Donut Breakdown
Data · Refined