# Diff Viewer

> Side-by-side and unified text diff with line numbers and word-level highlights, stacking to unified on narrow screens.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/diff-viewer
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/diff-viewer.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/diff-viewer-demo.tsx"
import { DiffViewer } from "@/components/diff-viewer"

const before = `import { fetchUser } from "./api"

export async function loadProfile(id: string) {
  const user = await fetchUser(id)
  if (!user) {
    throw new Error("User not found")
  }
  const displayName = user.firstName + " " + user.lastName
  console.log("loaded", displayName)
  return { id: user.id, name: displayName, plan: "free" }
}
`

const after = `import { fetchUser, fetchPlan } from "./api"

export async function loadProfile(id: string, signal?: AbortSignal) {
  const user = await fetchUser(id, { signal })
  if (!user) {
    throw new NotFoundError(\`User \${id} was not found in the directory service, please check the id and try again\`)
  }
  const displayName = \`\${user.firstName} \${user.lastName}\`.trim()
  const plan = await fetchPlan(user.id)
  return { id: user.id, name: displayName, plan: plan.slug }
}
`

export default function DiffViewerDemo() {
  return (
    <div className="w-full max-w-3xl min-w-0">
      <DiffViewer oldText={before} newText={after} filename="src/profile.ts" />
    </div>
  )
}
```

## Source

```tsx title="components/diff-viewer.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type DiffMode = "split" | "unified"

export interface DiffViewerProps extends Omit<React.ComponentProps<"div">, "children"> {
  oldText: string
  newText: string
  /** File name or title shown in the header */
  filename?: string
  defaultMode?: DiffMode
}

interface Seg {
  text: string
  changed: boolean
}

interface Line {
  no: number
  segs: Seg[]
}

interface UnifiedRow {
  kind: "ctx" | "del" | "add"
  oldNo?: number
  newNo?: number
  segs: Seg[]
}

type SplitRow = { left: Line | null; right: Line | null; kind: "ctx" | "change" }

interface Op {
  type: "equal" | "del" | "add"
  text: string
}

/** Classic LCS diff over two sequences of strings. */
function diffSeq(a: string[], b: string[]): Op[] {
  const n = a.length
  const m = b.length
  const w = m + 1
  const dp = new Uint32Array((n + 1) * w)
  for (let i = n - 1; i >= 0; i--) {
    for (let j = m - 1; j >= 0; j--) {
      dp[i * w + j] = a[i] === b[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1])
    }
  }
  const ops: Op[] = []
  let i = 0
  let j = 0
  while (i < n && j < m) {
    if (a[i] === b[j]) {
      ops.push({ type: "equal", text: a[i] })
      i++
      j++
    } else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) {
      ops.push({ type: "del", text: a[i++] })
    } else {
      ops.push({ type: "add", text: b[j++] })
    }
  }
  while (i < n) ops.push({ type: "del", text: a[i++] })
  while (j < m) ops.push({ type: "add", text: b[j++] })
  return ops
}

const tokenize = (s: string) => s.split(/(\s+|[^\w\s])/).filter(Boolean)

function pushSeg(segs: Seg[], text: string, changed: boolean) {
  const last = segs[segs.length - 1]
  if (last && last.changed === changed) last.text += text
  else segs.push({ text, changed })
}

/** Word-level highlight for a replaced line pair. Falls back to whole-line styling when lines share little. */
function wordDiff(oldLine: string, newLine: string): [Seg[], Seg[]] {
  const plain = (s: string): Seg[] => [{ text: s, changed: false }]
  const a = tokenize(oldLine)
  const b = tokenize(newLine)
  if (a.length * b.length > 250_000) return [plain(oldLine), plain(newLine)]
  const ops = diffSeq(a, b)
  const left: Seg[] = []
  const right: Seg[] = []
  let shared = 0
  for (const op of ops) {
    if (op.type === "equal") {
      shared += op.text.length
      pushSeg(left, op.text, false)
      pushSeg(right, op.text, false)
    } else if (op.type === "del") {
      pushSeg(left, op.text, true)
    } else {
      pushSeg(right, op.text, true)
    }
  }
  if (shared / Math.max(oldLine.length, newLine.length, 1) < 0.35) return [plain(oldLine), plain(newLine)]
  return [left, right]
}

function buildDiff(oldText: string, newText: string) {
  const split = (t: string) => (t === "" ? [] : t.replace(/\n$/, "").split("\n"))
  const ops = diffSeq(split(oldText), split(newText))
  const unified: UnifiedRow[] = []
  const rows: SplitRow[] = []
  let oldNo = 1
  let newNo = 1
  let added = 0
  let removed = 0
  let dels: { no: number; text: string }[] = []
  let adds: { no: number; text: string }[] = []

  const flush = () => {
    if (dels.length === 0 && adds.length === 0) return
    const pairs = Math.min(dels.length, adds.length)
    const delSegs: Seg[][] = []
    const addSegs: Seg[][] = []
    for (let k = 0; k < Math.max(dels.length, adds.length); k++) {
      if (k < pairs) {
        const [l, r] = wordDiff(dels[k].text, adds[k].text)
        delSegs.push(l)
        addSegs.push(r)
      } else {
        if (dels[k]) delSegs.push([{ text: dels[k].text, changed: false }])
        if (adds[k]) addSegs.push([{ text: adds[k].text, changed: false }])
      }
    }
    dels.forEach((d, k) => unified.push({ kind: "del", oldNo: d.no, segs: delSegs[k] }))
    adds.forEach((a, k) => unified.push({ kind: "add", newNo: a.no, segs: addSegs[k] }))
    for (let k = 0; k < Math.max(dels.length, adds.length); k++) {
      rows.push({
        kind: "change",
        left: dels[k] ? { no: dels[k].no, segs: delSegs[k] } : null,
        right: adds[k] ? { no: adds[k].no, segs: addSegs[k] } : null,
      })
    }
    removed += dels.length
    added += adds.length
    dels = []
    adds = []
  }

  for (const op of ops) {
    if (op.type === "del") dels.push({ no: oldNo++, text: op.text })
    else if (op.type === "add") adds.push({ no: newNo++, text: op.text })
    else {
      flush()
      const segs = [{ text: op.text, changed: false }]
      unified.push({ kind: "ctx", oldNo, newNo, segs })
      rows.push({ kind: "ctx", left: { no: oldNo, segs }, right: { no: newNo, segs } })
      oldNo++
      newNo++
    }
  }
  flush()
  return { unified, rows, added, removed }
}

const WIDE_QUERY = "(min-width: 640px)"

function subscribeWide(onChange: () => void) {
  const mq = window.matchMedia(WIDE_QUERY)
  mq.addEventListener("change", onChange)
  return () => mq.removeEventListener("change", onChange)
}

function useIsWide() {
  return React.useSyncExternalStore(
    subscribeWide,
    () => window.matchMedia(WIDE_QUERY).matches,
    () => true
  )
}

const lineTone = {
  add: {
    row: "bg-emerald-500/10",
    gutter: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
    mark: "text-emerald-700 dark:text-emerald-300",
    word: "bg-emerald-500/30 rounded-[3px]",
    marker: "+",
  },
  del: {
    row: "bg-rose-500/10",
    gutter: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
    mark: "text-rose-700 dark:text-rose-300",
    word: "bg-rose-500/30 rounded-[3px]",
    marker: "-",
  },
  ctx: { row: "", gutter: "text-muted-foreground", mark: "", word: "", marker: " " },
} as const

function Code({ segs, wordClass }: { segs: Seg[]; wordClass: string }) {
  return (
    <span className="pr-4 whitespace-pre">
      {segs.length === 0 || (segs.length === 1 && segs[0].text === "")
        ? " "
        : segs.map((s, i) => (
            <span key={i} className={cn(s.changed && wordClass)}>
              {s.text}
            </span>
          ))}
    </span>
  )
}

function Cell({
  tone,
  no,
  segs,
  extra,
}: {
  tone: keyof typeof lineTone
  no?: number
  segs: Seg[]
  extra?: string
}) {
  const t = lineTone[tone]
  return (
    <div className={cn("flex min-h-6", t.row, extra)}>
      <span aria-hidden className={cn("w-10 shrink-0 pr-2 text-right select-none tabular-nums", t.gutter)}>
        {no}
      </span>
      <span aria-hidden={tone === "ctx"} className={cn("w-5 shrink-0 text-center select-none", t.mark)}>
        {t.marker}
      </span>
      <Code segs={segs} wordClass={t.word} />
    </div>
  )
}

const toggleBtn =
  "h-7 rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"

export function DiffViewer({ oldText, newText, filename, defaultMode = "split", className, ...props }: DiffViewerProps) {
  const [mode, setMode] = React.useState<DiffMode>(defaultMode)
  const wide = useIsWide()
  const effective: DiffMode = wide ? mode : "unified"
  const diff = React.useMemo(() => buildDiff(oldText, newText), [oldText, newText])
  const unchanged = diff.added === 0 && diff.removed === 0

  return (
    <div className={cn("w-full min-w-0 overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm", className)} {...props}>
      <div className="flex min-h-10 flex-wrap items-center justify-between gap-x-3 gap-y-1 border-b bg-muted/50 py-1 pr-1.5 pl-4">
        <div className="flex min-w-0 items-center gap-3">
          <span className="min-w-0 truncate font-mono text-xs text-muted-foreground">{filename ?? "Changes"}</span>
          <span className="flex shrink-0 gap-2 font-mono text-xs tabular-nums">
            <span className="text-emerald-600 dark:text-emerald-400" aria-label={`${diff.added} lines added`}>
              +{diff.added}
            </span>
            <span className="text-rose-600 dark:text-rose-400" aria-label={`${diff.removed} lines removed`}>
              -{diff.removed}
            </span>
          </span>
        </div>
        <div role="group" aria-label="Diff view" className="hidden items-center gap-0.5 rounded-lg bg-background/60 p-0.5 sm:flex">
          {(["split", "unified"] as const).map((m) => (
            <button
              key={m}
              type="button"
              aria-pressed={mode === m}
              onClick={() => setMode(m)}
              className={cn(toggleBtn, mode === m ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
            >
              {m === "split" ? "Split" : "Unified"}
            </button>
          ))}
        </div>
      </div>
      <div className="overflow-x-auto" tabIndex={0} role="region" aria-label={`Diff${filename ? ` of ${filename}` : ""}, ${effective} view`}>
        {unchanged ? (
          <p className="px-4 py-6 text-center text-sm text-muted-foreground">No differences</p>
        ) : effective === "unified" ? (
          <div className="w-max min-w-full py-2 font-mono text-[13px] leading-6" data-view="unified">
            {diff.unified.map((row, i) => {
              const t = lineTone[row.kind]
              return (
                <div key={i} className={cn("flex min-h-6", t.row)}>
                  <span aria-hidden className={cn("w-10 shrink-0 pr-2 text-right select-none tabular-nums", t.gutter)}>
                    {row.oldNo}
                  </span>
                  <span aria-hidden className={cn("w-10 shrink-0 pr-2 text-right select-none tabular-nums", t.gutter)}>
                    {row.newNo}
                  </span>
                  <span className={cn("w-5 shrink-0 text-center select-none", t.mark)}>{t.marker}</span>
                  <Code segs={row.segs} wordClass={t.word} />
                </div>
              )
            })}
          </div>
        ) : (
          <div className="grid w-max min-w-full grid-cols-2 py-2 font-mono text-[13px] leading-6" data-view="split">
            {diff.rows.map((row, i) => (
              <React.Fragment key={i}>
                {row.left ? (
                  <Cell tone={row.kind === "ctx" ? "ctx" : "del"} no={row.left.no} segs={row.left.segs} />
                ) : (
                  <div aria-hidden className="min-h-6 bg-muted/40" />
                )}
                {row.right ? (
                  <Cell tone={row.kind === "ctx" ? "ctx" : "add"} no={row.right.no} segs={row.right.segs} extra="border-l" />
                ) : (
                  <div aria-hidden className="min-h-6 border-l bg-muted/40" />
                )}
              </React.Fragment>
            ))}
          </div>
        )}
      </div>
    </div>
  )
}
```
