Render code diffs with syntax highlighting for additions and deletions.
This is a standalone component that does not depend on the assistant-ui runtime.
Installation
npx shadcn@latest add https://r.assistant-ui.com/diff-viewer.jsonMain Component
npm install @assistant-ui/react-markdown class-variance-authority diff parse-diff"use client";import type { ComponentProps } from "react";import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown";import { cva, type VariantProps } from "class-variance-authority";import { diffLines } from "diff";import parseDiff from "parse-diff";import { useMemo } from "react";import { cn } from "@/lib/utils";type DiffLineType = "add" | "del" | "normal";interface ParsedLine { type: DiffLineType; content: string; oldLineNumber?: number; newLineNumber?: number;}interface ParsedFile { oldName?: string | undefined; newName?: string | undefined; lines: ParsedLine[]; additions: number; deletions: number;}interface SplitLinePair { left: ParsedLine | null; right: ParsedLine | null;}function parsePatch(patch: string): ParsedFile[] { const files = parseDiff(patch); return files.map((file) => { const lines: ParsedLine[] = []; let additions = 0; let deletions = 0; for (const chunk of file.chunks) { let oldLine = chunk.oldStart; let newLine = chunk.newStart; for (const change of chunk.changes) { if (change.type === "add") { additions++; lines.push({ type: "add", content: change.content.slice(1), newLineNumber: newLine++, }); } else if (change.type === "del") { deletions++; lines.push({ type: "del", content: change.content.slice(1), oldLineNumber: oldLine++, }); } else { lines.push({ type: "normal", content: change.content.slice(1), oldLineNumber: oldLine++, newLineNumber: newLine++, }); } } } return { oldName: file.from, newName: file.to, lines, additions, deletions, }; });}function computeDiff( oldContent: string, newContent: string,): { lines: ParsedLine[]; additions: number; deletions: number } { const changes = diffLines(oldContent, newContent); const lines: ParsedLine[] = []; let oldLine = 1; let newLine = 1; let additions = 0; let deletions = 0; for (const change of changes) { const contentLines = change.value.replace(/\n$/, "").split("\n"); for (const content of contentLines) { if (change.added) { additions++; lines.push({ type: "add", content, newLineNumber: newLine++ }); } else if (change.removed) { deletions++; lines.push({ type: "del", content, oldLineNumber: oldLine++ }); } else { lines.push({ type: "normal", content, oldLineNumber: oldLine++, newLineNumber: newLine++, }); } } } return { lines, additions, deletions };}function pairLinesForSplit(lines: ParsedLine[]): SplitLinePair[] { const pairs: SplitLinePair[] = []; let i = 0; while (i < lines.length) { const line = lines[i]!; if (line.type === "normal") { pairs.push({ left: line, right: line }); i++; } else if (line.type === "del") { const deletions: ParsedLine[] = []; while (i < lines.length && lines[i]!.type === "del") { deletions.push(lines[i]!); i++; } const additions: ParsedLine[] = []; while (i < lines.length && lines[i]!.type === "add") { additions.push(lines[i]!); i++; } const maxLen = Math.max(deletions.length, additions.length); for (let j = 0; j < maxLen; j++) { pairs.push({ left: deletions[j] ?? null, right: additions[j] ?? null, }); } } else { pairs.push({ left: null, right: line }); i++; } } return pairs;}const diffViewerVariants = cva( "aui-diff-viewer overflow-hidden rounded-lg font-mono text-sm", { variants: { variant: { default: "border bg-background", ghost: "bg-transparent", muted: "border border-muted-foreground/20 bg-muted", }, size: { sm: "text-xs", default: "text-sm", lg: "text-base", }, }, defaultVariants: { variant: "default", size: "default", }, },);const diffLineVariants = cva("flex", { variants: { type: { add: "bg-[var(--diff-add-bg,_rgba(46,160,67,0.15))]", del: "bg-[var(--diff-del-bg,_rgba(248,81,73,0.15))]", normal: "", empty: "", }, }, defaultVariants: { type: "normal", },});const diffLineTextVariants = cva("", { variants: { type: { add: "text-[var(--diff-add-text,_#1a7f37)] dark:text-[var(--diff-add-text-dark,_#3fb950)]", del: "text-[var(--diff-del-text,_#cf222e)] dark:text-[var(--diff-del-text-dark,_#f85149)]", normal: "", empty: "", }, }, defaultVariants: { type: "normal", },});function getFileExtension(filename?: string): string { const ext = filename?.split(".").pop()?.toLowerCase(); if (!ext) return ""; return ext.toUpperCase();}function DiffViewerFileBadge({ filename }: { filename?: string | undefined }) { const ext = getFileExtension(filename); if (!ext) return null; return ( <span data-slot="diff-viewer-file-badge" className="inline-flex size-5 shrink-0 items-end justify-end rounded-sm border bg-background font-bold text-[8px] leading-none" > <span className="p-0.5">{ext}</span> </span> );}function DiffViewerStats({ additions, deletions,}: { additions: number; deletions: number;}) { return ( <span data-slot="diff-viewer-stats" className="flex gap-2 text-xs"> <span className="text-green-600 dark:text-green-400">+{additions}</span> <span className="text-red-600 dark:text-red-400">-{deletions}</span> </span> );}function DiffViewerFile({ className, ...props }: ComponentProps<"div">) { return ( <div data-slot="diff-viewer-file" className={cn(className)} {...props} /> );}function DiffViewerContent({ className, ...props }: ComponentProps<"div">) { return ( <div data-slot="diff-viewer-content" className={cn("overflow-x-auto", className)} {...props} /> );}interface DiffViewerHeaderProps extends ComponentProps<"div"> { oldName?: string | undefined; newName?: string | undefined; additions?: number; deletions?: number; showIcon?: boolean; showStats?: boolean;}function DiffViewerHeader({ oldName, newName, additions = 0, deletions = 0, showIcon = true, showStats = true, className, ...props}: DiffViewerHeaderProps) { if (!oldName && !newName) return null; const displayName = newName || oldName; return ( <div data-slot="diff-viewer-header" className={cn( "flex items-center gap-2 border-b bg-muted px-4 py-2 text-muted-foreground", className, )} {...props} > {showIcon && <DiffViewerFileBadge filename={displayName} />} <span className="flex-1"> {oldName && newName && oldName !== newName ? ( <> <span className="text-red-600 dark:text-red-400">{oldName}</span> {" → "} <span className="text-green-600 dark:text-green-400"> {newName} </span> </> ) : ( displayName )} </span> {showStats && (additions > 0 || deletions > 0) && ( <DiffViewerStats additions={additions} deletions={deletions} /> )} </div> );}interface DiffViewerLineProps extends ComponentProps<"div"> { line: ParsedLine; showLineNumbers?: boolean;}function DiffViewerLine({ line, showLineNumbers = true, className, ...props}: DiffViewerLineProps) { const indicator = line.type === "add" ? "+" : line.type === "del" ? "-" : " "; return ( <div data-slot="diff-viewer-line" data-type={line.type} className={cn(diffLineVariants({ type: line.type }), className)} {...props} > {showLineNumbers && ( <span data-slot="diff-viewer-line-number" className="w-12 shrink-0 select-none px-2 text-right text-muted-foreground" > {line.type === "del" ? line.oldLineNumber : line.type === "add" ? line.newLineNumber : line.oldLineNumber} </span> )} <span data-slot="diff-viewer-indicator" className={cn( "w-4 shrink-0 select-none text-center", diffLineTextVariants({ type: line.type }), )} > {indicator} </span> <span data-slot="diff-viewer-content" className={cn( "flex-1 whitespace-pre-wrap break-all", diffLineTextVariants({ type: line.type }), )} > {line.content} </span> </div> );}interface DiffViewerSplitLineProps extends ComponentProps<"div"> { pair: SplitLinePair; showLineNumbers?: boolean;}function DiffViewerSplitLine({ pair, showLineNumbers = true, className, ...props}: DiffViewerSplitLineProps) { const { left, right } = pair; return ( <div data-slot="diff-viewer-split-line" className={cn("flex", className)} {...props} > <div data-slot="diff-viewer-split-left" data-type={left?.type ?? "empty"} className={cn( "flex w-1/2 border-r", diffLineVariants({ type: left?.type ?? "empty" }), )} > {showLineNumbers && ( <span className="w-12 shrink-0 select-none px-2 text-right text-muted-foreground"> {left?.oldLineNumber ?? ""} </span> )} <span className={cn( "w-4 shrink-0 select-none text-center", diffLineTextVariants({ type: left?.type ?? "empty" }), )} > {left ? (left.type === "del" ? "-" : " ") : ""} </span> <span className={cn( "flex-1 whitespace-pre-wrap break-all", diffLineTextVariants({ type: left?.type ?? "empty" }), )} > {left?.content ?? ""} </span> </div> <div data-slot="diff-viewer-split-right" data-type={right?.type ?? "empty"} className={cn( "flex w-1/2", diffLineVariants({ type: right?.type ?? "empty" }), )} > {showLineNumbers && ( <span className="w-12 shrink-0 select-none px-2 text-right text-muted-foreground"> {right?.newLineNumber ?? ""} </span> )} <span className={cn( "w-4 shrink-0 select-none text-center", diffLineTextVariants({ type: right?.type ?? "empty" }), )} > {right ? (right.type === "add" ? "+" : " ") : ""} </span> <span className={cn( "flex-1 whitespace-pre-wrap break-all", diffLineTextVariants({ type: right?.type ?? "empty" }), )} > {right?.content ?? ""} </span> </div> </div> );}export type DiffViewerProps = Partial<SyntaxHighlighterProps> & VariantProps<typeof diffViewerVariants> & { patch?: string; oldFile?: { content: string; name?: string }; newFile?: { content: string; name?: string }; viewMode?: "split" | "unified"; showLineNumbers?: boolean; showIcon?: boolean; showStats?: boolean; className?: string; };function DiffViewer({ code, patch, oldFile, newFile, viewMode = "unified", showLineNumbers = true, showIcon = true, showStats = true, variant, size, className,}: DiffViewerProps) { const diffPatch = patch ?? code; const parsedFiles = useMemo(() => { if (diffPatch) { return parsePatch(diffPatch); } if (oldFile && newFile) { const { lines, additions, deletions } = computeDiff( oldFile.content, newFile.content, ); return [ { oldName: oldFile.name, newName: newFile.name, lines, additions, deletions, }, ]; } return []; }, [diffPatch, oldFile, newFile]); if (parsedFiles.length === 0) { return ( <pre data-slot="diff-viewer" className={cn("rounded-lg bg-muted p-4", className)} > No diff content provided </pre> ); } return ( <div data-slot="diff-viewer" data-view-mode={viewMode} data-variant={variant ?? "default"} data-size={size ?? "default"} className={cn(diffViewerVariants({ variant, size }), className)} > {parsedFiles.map((file, fileIndex) => ( <div key={fileIndex} data-slot="diff-viewer-file"> <DiffViewerHeader oldName={file.oldName} newName={file.newName} additions={file.additions} deletions={file.deletions} showIcon={showIcon} showStats={showStats} /> <div data-slot="diff-viewer-content" className="overflow-x-auto"> {viewMode === "split" ? pairLinesForSplit(file.lines).map((pair, pairIndex) => ( <DiffViewerSplitLine key={pairIndex} pair={pair} showLineNumbers={showLineNumbers} /> )) : file.lines.map((line, lineIndex) => ( <DiffViewerLine key={lineIndex} line={line} showLineNumbers={showLineNumbers} /> ))} </div> </div> ))} </div> );}DiffViewer.displayName = "DiffViewer";export type { ParsedLine, ParsedFile, SplitLinePair };export { DiffViewer, DiffViewerFile, DiffViewerHeader, DiffViewerContent, DiffViewerLine, DiffViewerSplitLine, DiffViewerFileBadge, DiffViewerStats, diffViewerVariants, diffLineVariants, diffLineTextVariants, parsePatch, computeDiff,};Usage
import { DiffViewer } from "@/components/assistant-ui/diff-viewer";
// With a unified diff patch
<DiffViewer patch={diffString} />
// With file comparison
<DiffViewer
oldFile={{ content: "old content", name: "file.txt" }}
newFile={{ content: "new content", name: "file.txt" }}
/>As Markdown Language Override
Integrate with MarkdownTextPrimitive to render diff code blocks:
import { DiffViewer } from "@/components/assistant-ui/diff-viewer";
const MarkdownTextImpl = () => {
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}
className="aui-md"
components={defaultComponents}
componentsByLanguage={{
diff: {
SyntaxHighlighter: ({ code }) => <DiffViewer patch={code} />
},
}}
/>
);
};
export const MarkdownText = memo(MarkdownTextImpl);Examples
Unified View
Shows all changes in a single column with +/- indicators. This is the default mode.
<DiffViewer patch={diffString} viewMode="unified" />Split View
Shows old content on the left, new content on the right side-by-side.
<DiffViewer patch={diffString} viewMode="split" />Interactive Mode Toggle
Variants
Sizes
Theming
DiffViewer uses CSS variables for colors. Override them in your CSS:
[data-slot="diff-viewer"] {
--diff-add-bg: rgba(46, 160, 67, 0.15);
--diff-add-text: #1a7f37;
--diff-add-text-dark: #3fb950;
--diff-del-bg: rgba(248, 81, 73, 0.15);
--diff-del-text: #cf222e;
--diff-del-text-dark: #f85149;
}| Variable | Description |
|---|---|
--diff-add-bg | Background for added lines |
--diff-add-text | Text color for added lines (light mode) |
--diff-add-text-dark | Text color for added lines (dark mode) |
--diff-del-bg | Background for deleted lines |
--diff-del-text | Text color for deleted lines (light mode) |
--diff-del-text-dark | Text color for deleted lines (dark mode) |
API Reference
DiffViewer
The main component for rendering diffs.
DiffViewerPropspatch?: stringUnified diff string (e.g., output from git diff).
code?: stringAlias for patch (for markdown integration).
oldFile?: { content: string; name?: string }Old file for direct comparison.
newFile?: { content: string; name?: string }New file for direct comparison.
viewMode: "unified" | "split"= "unified"Display mode for the diff.
variant: "default" | "ghost" | "muted"= "default"Visual style variant.
size: "sm" | "default" | "lg"= "default"Font size.
showLineNumbers: boolean= trueShow line numbers.
showIcon: boolean= trueShow file extension badge in header.
showStats: boolean= trueShow addition/deletion counts in header.
className?: stringAdditional CSS classes.
Composable API
| Component | Description |
|---|---|
DiffViewer | Main component that renders the diff. |
DiffViewerFile | Wrapper for each file in multi-file diffs. |
DiffViewerHeader | File name header with icon and stats. |
DiffViewerContent | Scrollable content area. |
DiffViewerLine | Individual line in unified mode. |
DiffViewerSplitLine | Side-by-side line pair in split mode. |
DiffViewerFileBadge | File extension badge (e.g., "TS"). |
DiffViewerStats | Addition/deletion count display. |
Style Variants (CVA)
| Export | Description |
|---|---|
diffViewerVariants | Styles for the root container. |
diffLineVariants | Background styles for diff lines. |
diffLineTextVariants | Text color styles for diff lines. |
import {
diffViewerVariants,
diffLineVariants,
diffLineTextVariants,
} from "@/components/assistant-ui/diff-viewer";
// Use variants directly
<div className={diffViewerVariants({ variant: "ghost", size: "sm" })}>
Custom diff container
</div>Utilities
| Export | Description |
|---|---|
parsePatch(patch) | Parse unified diff string into structured data. |
computeDiff(old, new) | Compute diff between two strings. |
ParsedLine | Type for a single diff line. |
ParsedFile | Type for a parsed file with lines and stats. |
SplitLinePair | Type for a side-by-side line pair. |
Styling
Data Attributes
Use data attributes for custom styling:
| Attribute | Values | Description |
|---|---|---|
data-slot | "diff-viewer", "diff-viewer-header", "diff-viewer-line", etc. | Component identification |
data-type | "add", "del", "normal", "empty" | Line type |
data-view-mode | "unified", "split" | Current view mode |
data-variant | "default", "ghost", "muted" | Current variant |
Custom CSS Example
[data-slot="diff-viewer"][data-view-mode="split"] {
/* Custom split view styles */
}
[data-slot="diff-viewer-line"][data-type="add"] {
/* Custom addition styles */
}
[data-slot="diff-viewer-line"][data-type="del"] {
/* Custom deletion styles */
}Related Components
- Markdown - Rich text rendering where diff viewer can be integrated
- Syntax Highlighting - Code highlighting for other languages