A dependency-light text diff with add/remove row tinting, expandable collapsed context, tabular line numbers, and a per-line copy affordance on hover.
npx shadcn@latest add @paragon/diff-viewer"use client";
import * as React from "react";
import { Check, Copy, UnfoldVertical } from "lucide-react";
import { cn } from "@/lib/utils";
export type DiffLineType = "add" | "remove" | "context";
export interface DiffLine {
/** Defaults to "context". */
type?: DiffLineType;
content: string;
}
export interface DiffHunk {
/** Renders as a "show N hidden lines" affordance until expanded. */
collapsed?: boolean;
lines: DiffLine[];
}
interface NumberedLine {
type: DiffLineType;
content: string;
old: number | null;
new: number | null;
key: string;
}
export interface DiffViewerProps extends React.ComponentProps<"div"> {
hunks: DiffHunk[];
/** Optional header with the file path and +/− stats. */
fileName?: string;
startOldLine?: number;
startNewLine?: number;
}
const rowTint: Record<DiffLineType, string> = {
add: "bg-emerald-500/8 hover:bg-emerald-500/15 dark:bg-emerald-500/10",
remove: "bg-red-500/8 hover:bg-red-500/15 dark:bg-red-500/10",
context: "hover:bg-muted/40",
};
const markerColor: Record<DiffLineType, string> = {
add: "text-emerald-600 dark:text-emerald-400",
remove: "text-red-600 dark:text-red-400",
context: "text-transparent",
};
const marker: Record<DiffLineType, string> = {
add: "+",
remove: "-",
context: " ",
};
/**
* A dependency-light text diff. Added and removed lines tint the full
* row; collapsed context expands in place through the grid-rows
* transition (the reveal grows on ease-out while the affordance folds
* away on ease-exit). Hovering a line reveals a copy affordance —
* opacity only, and always reachable by keyboard regardless of hover.
* Monospace, no syntax highlighting.
*/
export function DiffViewer({
hunks,
fileName,
startOldLine = 1,
startNewLine = 1,
className,
...props
}: DiffViewerProps) {
const [expandedHunks, setExpandedHunks] = React.useState<Set<number>>(
() => new Set(),
);
const [copiedKey, setCopiedKey] = React.useState<string | null>(null);
const copyTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);
React.useEffect(() => {
return () => {
if (copyTimeout.current) clearTimeout(copyTimeout.current);
};
}, []);
const copyLine = (key: string, content: string) => {
navigator.clipboard.writeText(content);
setCopiedKey(key);
if (copyTimeout.current) clearTimeout(copyTimeout.current);
copyTimeout.current = setTimeout(() => setCopiedKey(null), 1500);
};
// Number every line up front so hidden hunks keep the count honest.
const numbered = React.useMemo(() => {
let oldLine = startOldLine;
let newLine = startNewLine;
let adds = 0;
let removes = 0;
const result: NumberedLine[][] = hunks.map((hunk, hi) =>
hunk.lines.map((line, li) => {
const type = line.type ?? "context";
if (type === "add") adds++;
if (type === "remove") removes++;
return {
type,
content: line.content,
old: type === "add" ? null : oldLine++,
new: type === "remove" ? null : newLine++,
key: `${hi}-${li}`,
};
}),
);
return { result, adds, removes };
}, [hunks, startOldLine, startNewLine]);
const renderLine = (line: NumberedLine) => {
const isCopied = copiedKey === line.key;
return (
<div
key={line.key}
className={cn(
"group/line relative flex w-max min-w-full items-stretch transition-colors duration-(--duration-fast)",
rowTint[line.type],
)}
>
<span
aria-hidden
className="w-10 shrink-0 py-0.5 pr-2 text-right text-muted-foreground/50 tabular-nums select-none"
>
{line.old ?? ""}
</span>
<span
aria-hidden
className="w-10 shrink-0 py-0.5 pr-2 text-right text-muted-foreground/50 tabular-nums select-none"
>
{line.new ?? ""}
</span>
<span
className={cn(
"w-5 shrink-0 py-0.5 text-center select-none",
markerColor[line.type],
)}
>
{marker[line.type]}
</span>
<span className="flex-1 py-0.5 pr-9 whitespace-pre">{line.content}</span>
<button
type="button"
aria-label={`Copy line ${line.new ?? line.old ?? ""}`.trim()}
onClick={() => copyLine(line.key, line.content)}
className={cn(
"absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground",
"opacity-0 transition-opacity duration-(--duration-fast) group-hover/line:opacity-100 focus-visible:opacity-100 hover:text-foreground",
"after:absolute after:top-1/2 after:left-1/2 after:size-7 after:-translate-1/2",
)}
>
<span className="relative flex size-3 items-center justify-center">
<Copy
className={cn(
"absolute size-3 transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out)",
isCopied ? "scale-50 opacity-0" : "scale-100 opacity-100",
)}
/>
<Check
className={cn(
"absolute size-3 text-emerald-600 transition-[opacity,scale] duration-(--duration-quick) ease-(--ease-out) dark:text-emerald-400",
isCopied ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
/>
</span>
</button>
</div>
);
};
return (
<div
data-slot="diff-viewer"
className={cn(
"w-full overflow-hidden rounded-xl bg-card font-mono text-xs shadow-border",
className,
)}
{...props}
>
{fileName && (
<div className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
<span className="truncate font-medium text-foreground">{fileName}</span>
<span className="shrink-0 tabular-nums">
<span className="text-emerald-600 dark:text-emerald-400">
+{numbered.adds}
</span>{" "}
<span className="text-red-600 dark:text-red-400">
−{numbered.removes}
</span>
</span>
</div>
)}
<div className="overflow-x-auto py-1">
{hunks.map((hunk, hi) => {
const lines = numbered.result[hi];
if (!hunk.collapsed) {
return <div key={hi}>{lines.map(renderLine)}</div>;
}
const isExpanded = expandedHunks.has(hi);
return (
<div key={hi}>
<div
className={cn(
"grid transition-[grid-template-rows] duration-(--duration-quick) ease-(--ease-exit) motion-reduce:transition-none",
isExpanded ? "grid-rows-[0fr]" : "grid-rows-[1fr]",
)}
>
<div inert={isExpanded} className="min-h-0 overflow-hidden">
<button
type="button"
onClick={() =>
setExpandedHunks((prev) => new Set(prev).add(hi))
}
className="flex h-8 w-full items-center justify-center gap-2 bg-muted/40 text-[11px] text-muted-foreground transition-colors duration-(--duration-fast) hover:bg-muted/70 hover:text-foreground"
>
<UnfoldVertical aria-hidden className="size-3.5" />
Show {hunk.lines.length} hidden lines
</button>
</div>
</div>
<div
className={cn(
"grid transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div inert={!isExpanded} className="min-h-0 overflow-hidden">
{lines.map(renderLine)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}