Primitives
Code Diff
Unified or split diff from a git patch or a before/after pair, with word-level intra-line highlighting.
Examples
Headerless copy
Use the same copy affordance when a diff does not need its filename and stats row.
Composition
Rendered diff
Pass a patch or oldText/newText. CodeDiff renders the files and its copy action internally.
- <CodeDiff>
- built-in header actions
- <CodeDiffCopy />
- built-in header actions
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
The Control UI source installs this primitive from src/registry/sources/control-ui/ui/code-diff.tsx.
npx shadcn@latest add https://control-ui.dev/r/code-diff.jsonDependencies
Diff engine
src/registry/lib/diff.tsSupportShiki tokenizer
src/registry/lib/code-tokens.tsSupportCode + diff tokens
Codesrc/registry/sources/control-ui/code.cssSupportcodePrimitiveScroll areascroll-areaPrimitiveRaw code
Primary installed source
"use client";import { useVirtualizer } from "@tanstack/react-virtual";import type { ComponentProps, CSSProperties, ReactNode } from "react";import { useEffect, useMemo, useRef, useState } from "react";import { flushSync } from "react-dom";import type { CodeDiffKnobStyle } from "@/components/control-ui/knob-contracts/code-diff-knobs";import { cn } from "@/components/control-ui/lib/cn";import { type CodeTokenLines, highlightToTokens, mergeCodeTokenLineWithEmphasis } from "@/components/control-ui/lib/code-tokens";import { buildDiffFromFiles, buildDiffFromPatch, type DiffFile, type DiffLine, diffRunEnd } from "@/components/control-ui/lib/diff";import type { CodeOverflow } from "@/components/control-ui/ui/code";import { CodeCopy, type CodeCopyProps, CodeFloatingCopy, CodeTokenLine } from "@/components/control-ui/ui/code";import { ScrollArea } from "@/components/control-ui/ui/scroll-area";export type DiffStyle = "unified" | "split";export type DiffIndicators = "classic" | "bars";export type DiffLineKind = "word" | "char" | "none";/* * Split mode virtualizes one aligned-row list rather than two synced panes, so there is no scroll-sync to drift. * Line numbers and +/- markers sit in select-none cells, so text selection copies clean source. */const ESTIMATED_ROW_HEIGHT = 20;const VIRTUALIZE_THRESHOLD = 150;type SideTokens = { old: CodeTokenLines | null; new: CodeTokenLines | null };type VisualRow = | { kind: "separator"; id: string; gapIndex: number; label: string; canExpand: boolean } | { kind: "unified"; id: string; line: DiffLine } | { kind: "split"; id: string; left: DiffLine | null; right: DiffLine | null };export type CodeDiffProps = Omit<ComponentProps<"figure">, "children" | "style"> & { // one path or other: `patch` is partial and cannot expand context, text pair can patch?: string; oldText?: string; newText?: string; lang?: string; name?: string; diffStyle?: DiffStyle; diffIndicators?: DiffIndicators; lineDiffType?: DiffLineKind; overflow?: CodeOverflow; maxLineDiffLength?: number; maxHeight?: string; header?: boolean; children?: ReactNode; style?: CSSProperties & CodeDiffKnobStyle;};// tokens are indexed by line number, so partial file's holes are rebuilt as blank lines to keep indices honestfunction sideTexts(file: DiffFile): { old: string; new: string } { if (!file.isPartial) return { old: file.oldLines.join("\n"), new: file.newLines.join("\n") }; const oldArr: string[] = []; const newArr: string[] = []; for (const hunk of file.hunks) { for (const line of hunk.lines) { if (line.oldNo !== undefined) oldArr[line.oldNo - 1] = line.text; if (line.newNo !== undefined) newArr[line.newNo - 1] = line.text; } } return { old: oldArr.join("\n"), new: newArr.join("\n") };}function useSideTokens(oldText: string, newText: string, lang: string | undefined, enabled: boolean): SideTokens { const requestKey = `${lang ?? ""}\n${oldText.length}:${oldText}${newText}`; const [state, setState] = useState<{ key: string; tokens: SideTokens } | null>(null); useEffect(() => { if (!enabled || !lang) return; let cancelled = false; void Promise.all([highlightToTokens(oldText, lang), highlightToTokens(newText, lang)]) .then(([oldTokens, newTokens]) => { if (!cancelled) setState({ key: requestKey, tokens: { old: oldTokens, new: newTokens } }); }) .catch(() => { if (!cancelled) setState({ key: requestKey, tokens: { old: null, new: null } }); }); return () => { cancelled = true; }; }, [oldText, newText, lang, enabled, requestKey]); if (!enabled) return { old: null, new: null }; return state?.key === requestKey ? state.tokens : { old: null, new: null };}function lineTokens(line: DiffLine, tokens: SideTokens): CodeTokenLines[number] | null { if (line.type === "del") return tokens.old?.[(line.oldNo ?? 1) - 1] ?? null; if (line.type === "add") return tokens.new?.[(line.newNo ?? 1) - 1] ?? null; return tokens.new?.[(line.newNo ?? 1) - 1] ?? tokens.old?.[(line.oldNo ?? 1) - 1] ?? null;}// context lines sit on both sides of pairfunction splitPairs(lines: DiffLine[]): { left: DiffLine | null; right: DiffLine | null }[] { const rows: { left: DiffLine | null; right: DiffLine | null }[] = []; let index = 0; while (index < lines.length) { const line = lines[index]; if (!line) break; if (line.type === "context") { rows.push({ left: line, right: line }); index += 1; continue; } const delEnd = diffRunEnd(lines, index, "del"); const addEnd = diffRunEnd(lines, delEnd, "add"); const dels = lines.slice(index, delEnd); const adds = lines.slice(delEnd, addEnd); const count = Math.max(dels.length, adds.length); for (let pair = 0; pair < count; pair += 1) rows.push({ left: dels[pair] ?? null, right: adds[pair] ?? null }); index = addEnd; } return rows;}// non-partial only — numbering comes from file's full textfunction expandedContext(file: DiffFile, gapIndex: number): DiffLine[] { const hunk = file.hunks[gapIndex]; if (!hunk) return []; const offset = hunk.newStart - hunk.oldStart; const lines: DiffLine[] = []; for (let oldNo = hunk.oldStart - hunk.collapsedBefore; oldNo < hunk.oldStart; oldNo += 1) { const text = file.oldLines[oldNo - 1] ?? ""; lines.push({ type: "context", oldNo, newNo: oldNo + offset, text }); } return lines;}type DiffHunk = DiffFile["hunks"][number];function expandedRowId(gapIndex: number, line: DiffLine): string { return `exp-${gapIndex}-${line.oldNo ?? line.newNo ?? 0}`;}function gapRows(file: DiffFile, hunk: DiffHunk, gapIndex: number, diffStyle: DiffStyle, expanded: ReadonlySet<number>): VisualRow[] { const hasGap = gapIndex > 0 || hunk.collapsedBefore > 0; if (!hasGap) return []; if (expanded.has(gapIndex) && !file.isPartial) { return expandedContext(file, gapIndex).map((line) => rowForLine(diffStyle, line, expandedRowId(gapIndex, line))); } const label = hunk.header ?? `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`; return [ { kind: "separator", id: `sep-${gapIndex}`, gapIndex, label, canExpand: !file.isPartial && hunk.collapsedBefore > 0, }, ];}function changedRows(hunk: DiffHunk, gapIndex: number, diffStyle: DiffStyle): VisualRow[] { if (diffStyle === "unified") { return hunk.lines.map((line, lineIndex) => rowForLine("unified", line, `u-${gapIndex}-${lineIndex}`)); } return splitPairs(hunk.lines).map((pair, pairIndex) => ({ kind: "split", id: `s-${gapIndex}-${pairIndex}`, left: pair.left, right: pair.right, }));}function buildRows(file: DiffFile, diffStyle: DiffStyle, expanded: ReadonlySet<number>): VisualRow[] { const rows: VisualRow[] = []; for (const [gapIndex, hunk] of file.hunks.entries()) { rows.push(...gapRows(file, hunk, gapIndex, diffStyle, expanded)); rows.push(...changedRows(hunk, gapIndex, diffStyle)); } return rows;}function rowForLine(diffStyle: DiffStyle, line: DiffLine, id: string): VisualRow { if (diffStyle === "unified") return { kind: "unified", id, line }; if (line.type === "context") return { kind: "split", id, left: line, right: line }; if (line.type === "del") return { kind: "split", id, left: line, right: null }; return { kind: "split", id, left: null, right: line };}const markerFor: Record<DiffLine["type"], string> = { add: "+", del: "-", context: "" };function DiffCode({ line, tokens, overflow,}: { line: DiffLine; tokens: CodeTokenLines[number] | null; overflow: CodeOverflow;}): ReactNode { const wrapClass = overflow === "wrap" ? "whitespace-pre-wrap break-words" : "whitespace-pre"; if (line.segments && line.segments.length > 0) { const runs = mergeCodeTokenLineWithEmphasis(line.text, tokens, line.segments); return ( <code className={cn("min-w-0 flex-1", wrapClass)}> {runs.map((run) => { const style: CSSProperties | undefined = run.style || undefined; if (run.emphasis) { return ( <span key={run.start} data-control-ui="code-diff" data-control-family="code-diff" data-slot="emphasis" data-line-type={line.type} style={style} > {run.content} </span> ); } return ( <span key={run.start} style={style}> {run.content} </span> ); })} </code> ); } return ( <code className={cn("min-w-0 flex-1", wrapClass)}> <CodeTokenLine tokens={tokens} plain={line.text} /> </code> );}function Gutter({ children, type }: { children: ReactNode; type: DiffLine["type"] }) { return ( <span data-control-ui="code-diff" data-control-family="code-diff" data-line-type={type} data-slot="gutter" aria-hidden="true" className="shrink-0 select-none" > {children} </span> );}function Marker({ type }: { type: DiffLine["type"] }) { return ( <span data-control-ui="code-diff" data-control-family="code-diff" data-slot="marker" data-line-type={type} aria-hidden="true" className="shrink-0 select-none" > {markerFor[type] || " "} </span> );}function UnifiedRow({ line, tokens, indicators, overflow,}: { line: DiffLine; tokens: CodeTokenLines[number] | null; indicators: DiffIndicators; overflow: CodeOverflow;}) { return ( <div data-control-ui="code-diff" data-control-family="code-diff" data-slot="line" data-line-type={line.type} data-indicators={indicators} aria-hidden="true" className="flex w-full" > <Gutter type={line.type}>{line.oldNo ?? ""}</Gutter> <Gutter type={line.type}>{line.newNo ?? ""}</Gutter> <Marker type={line.type} /> <DiffCode line={line} tokens={tokens} overflow={overflow} /> </div> );}function SplitHalf({ line, tokens, indicators, overflow, side,}: { line: DiffLine | null; tokens: CodeTokenLines[number] | null; indicators: DiffIndicators; overflow: CodeOverflow; side: "left" | "right";}) { if (!line) return ( <div data-control-ui="code-diff" data-control-family="code-diff" data-slot="empty-half" data-side={side} aria-hidden="true" className="flex min-w-0 flex-1" /> ); return ( <div data-control-ui="code-diff" data-control-family="code-diff" data-slot="line" data-line-type={line.type} data-indicators={indicators} data-side={side} aria-hidden="true" className="flex min-w-0 flex-1" > <Gutter type={line.type}>{side === "left" ? (line.oldNo ?? "") : (line.newNo ?? "")}</Gutter> <Marker type={line.type} /> <DiffCode line={line} tokens={tokens} overflow={overflow} /> </div> );}function fileTitle(file: DiffFile): string { return file.oldName && file.type === "rename" ? `${file.oldName} → ${file.name}` : file.name;}function DiffStats({ additions, deletions }: { additions: number; deletions: number }) { return ( <span data-control-ui="code-diff" data-control-family="code-diff" data-slot="stat" className="flex items-center"> <span data-control-ui="code-diff" data-control-family="code-diff" data-slot="stat-additions" aria-hidden="true"> +{additions} </span> <span data-control-ui="code-diff" data-control-family="code-diff" data-slot="stat-deletions" aria-hidden="true"> −{deletions} </span> <span className="sr-only"> {additions} {additions === 1 ? "addition" : "additions"}, {deletions} {deletions === 1 ? "deletion" : "deletions"} </span> </span> );}function lineDescription(label: string, line: DiffLine, number: number | undefined): string { return `${label} line ${number ?? "?"}: ${line.text}`;}function unifiedLineDescription(line: DiffLine): string { if (line.type === "add") return lineDescription("Added", line, line.newNo); if (line.type === "del") return lineDescription("Deleted", line, line.oldNo); return lineDescription("Unchanged", line, line.newNo ?? line.oldNo);}function splitRowDescriptions(row: Extract<VisualRow, { kind: "split" }>): string[] { if (row.left?.type === "context") return [lineDescription("Unchanged", row.left, row.left.newNo ?? row.left.oldNo)]; const descriptions: string[] = []; if (row.left) descriptions.push(lineDescription(row.right ? "Original" : "Deleted", row.left, row.left.oldNo)); if (row.right) descriptions.push(lineDescription(row.left ? "Modified" : "Added", row.right, row.right.newNo)); return descriptions;}function rowDescriptions(row: VisualRow): string[] { if (row.kind === "separator") return []; if (row.kind === "unified") return [unifiedLineDescription(row.line)]; return splitRowDescriptions(row);}function accessibleDiffText(file: DiffFile, rows: VisualRow[]): string { return [fileTitle(file), ...rows.flatMap(rowDescriptions)].join("\n");}function fileIdentity(file: DiffFile): string { const hunks = file.hunks .map((hunk) => `${hunk.oldStart}:${hunk.newStart}:${hunk.lines.map((line) => `${line.type}:${line.text}`).join("\n")}`) .join("\n"); return `${file.oldName ?? ""}->${file.name}:${file.type}:${hunks}`;}function CodeDiffFileSection({ file, lang, diffStyle, diffIndicators, overflow, maxHeight, showFileHeader,}: { file: DiffFile; lang: string | undefined; diffStyle: DiffStyle; diffIndicators: DiffIndicators; overflow: CodeOverflow; maxHeight: string; showFileHeader: boolean;}) { const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set()); const [revealedRowId, setRevealedRowId] = useState<string | null>(null); const texts = useMemo(() => sideTexts(file), [file]); const tokens = useSideTokens(texts.old, texts.new, lang, Boolean(lang)); const rows = useMemo(() => buildRows(file, diffStyle, expanded), [file, diffStyle, expanded]); const scrollRef = useRef<HTMLDivElement>(null); const revealedRowRef = useRef<HTMLDivElement>(null); const shouldVirtualize = rows.length > VIRTUALIZE_THRESHOLD; // react-doctor-disable-next-line react-hooks-js/incompatible-library const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollRef.current, estimateSize: () => ESTIMATED_ROW_HEIGHT, overscan: 24, enabled: shouldVirtualize, }); function expandGap(gapIndex: number) { const firstLine = expandedContext(file, gapIndex)[0]; flushSync(() => { setExpanded((current) => new Set(current).add(gapIndex)); setRevealedRowId(firstLine ? expandedRowId(gapIndex, firstLine) : null); }); revealedRowRef.current?.focus(); } function rowFrameProps(id: string) { if (id !== revealedRowId) return {}; return { tabIndex: -1, "data-control-ui": "code-diff", "data-control-family": "code-diff", "data-slot": "revealed-row" }; } function measureRevealedRow(node: HTMLDivElement | null) { revealedRowRef.current = node; virtualizer.measureElement(node); } const gridClassName = overflow === "scroll" ? "w-max min-w-full" : "w-full"; function renderRow(row: VisualRow): ReactNode { if (row.kind === "separator") { return ( <div data-control-ui="code-diff" data-control-family="code-diff" data-slot="expander" className="flex items-center"> {row.canExpand ? ( <button type="button" data-control-ui="code-diff" data-control-family="code-diff" data-slot="expand-button" data-control="true" onClick={() => expandGap(row.gapIndex)} className="cursor-pointer" aria-label="Expand hidden lines" > ⋯ </button> ) : ( <span aria-hidden="true">⋯</span> )} <span data-control-ui="code-diff" data-control-family="code-diff" data-slot="expander-label" title={row.label} className="truncate" > {row.label} </span> </div> ); } if (row.kind === "unified") { return <UnifiedRow line={row.line} tokens={lineTokens(row.line, tokens)} indicators={diffIndicators} overflow={overflow} />; } return ( <div data-control-ui="code-diff" data-control-family="code-diff" data-slot="row" className="flex w-full"> <SplitHalf line={row.left} tokens={row.left ? lineTokens(row.left, tokens) : null} indicators={diffIndicators} overflow={overflow} side="left" /> <SplitHalf line={row.right} tokens={row.right ? lineTokens(row.right, tokens) : null} indicators={diffIndicators} overflow={overflow} side="right" /> </div> ); } return ( <section data-control-ui="code-diff" data-control-family="code-diff" data-slot="file" data-file-name={file.name}> {showFileHeader ? ( <div data-control-ui="code-diff" data-control-family="code-diff" data-slot="file-header" className="flex items-center justify-between" > <span data-control-ui="code-diff" data-control-family="code-diff" data-slot="file-title" title={fileTitle(file)} className="min-w-0 truncate" > {fileTitle(file)} </span> <DiffStats additions={file.additions} deletions={file.deletions} /> </div> ) : null} <pre data-control-ui="code-diff" data-control-family="code-diff" data-slot="accessible-source" className="sr-only"> <code>{accessibleDiffText(file, rows)}</code> </pre> <ScrollArea maxHeight={maxHeight} aria-label={fileTitle(file)} viewportProps={{ "data-control-ui": "code-diff", "data-control-family": "code-diff", "data-slot": "body", }} viewportRef={scrollRef} > {shouldVirtualize ? ( <div className={gridClassName} style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}> {virtualizer.getVirtualItems().map((item) => { const row = rows[item.index]; if (!row) return null; return ( <div key={row.id} ref={row.id === revealedRowId ? measureRevealedRow : virtualizer.measureElement} data-index={item.index} {...rowFrameProps(row.id)} style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${item.start}px)` }} > {renderRow(row)} </div> ); })} </div> ) : ( <div className={gridClassName}> {rows.map((row) => ( <div key={row.id} ref={row.id === revealedRowId ? revealedRowRef : undefined} {...rowFrameProps(row.id)}> {renderRow(row)} </div> ))} </div> )} </ScrollArea> </section> );}export function CodeDiff({ patch, oldText, newText, lang, name, diffStyle = "unified", diffIndicators = "bars", lineDiffType = "word", overflow = "scroll", maxLineDiffLength, maxHeight = "32rem", header = true, className, style, ...props}: CodeDiffProps) { const options = { name, lang, lineDiffType, maxLineDiffLength }; const parsedFiles = patch === undefined ? [buildDiffFromFiles(oldText ?? "", newText ?? "", options)] : buildDiffFromPatch(patch, options); const files: DiffFile[] = parsedFiles.length > 0 ? parsedFiles : [emptyFile(name)]; const additions = files.reduce((total, file) => total + file.additions, 0); const deletions = files.reduce((total, file) => total + file.deletions, 0); const copyValue = patch ?? newText ?? ""; const firstFile = files[0]; const title = files.length === 1 && firstFile ? fileTitle(firstFile) : `${files.length} files`; return ( <figure data-control-ui="code-diff" data-control-family="code-diff" data-slot="root" data-surface="panel" dir="ltr" data-diff-style={diffStyle} data-file-count={files.length} data-header={header ? "true" : undefined} className={cn("overflow-hidden", className)} style={style} {...props} > {header ? ( <figcaption data-control-ui="code-diff" data-control-family="code-diff" data-slot="header" className="flex items-center justify-between" > <span data-control-ui="code-diff" data-control-family="code-diff" data-slot="title" title={title} className="min-w-0 truncate"> {title} </span> <div data-control-ui="code-diff" data-control-family="code-diff" data-slot="actions" className="flex shrink-0 items-center"> <DiffStats additions={additions} deletions={deletions} /> <CodeDiffCopy value={copyValue} /> </div> </figcaption> ) : ( <CodeFloatingCopy value={copyValue} /> )} {files.map((file) => ( <CodeDiffFileSection key={fileIdentity(file)} file={file} lang={lang} diffStyle={diffStyle} diffIndicators={diffIndicators} overflow={overflow} maxHeight={maxHeight} showFileHeader={files.length > 1} /> ))} </figure> );}export type CodeDiffCopyProps = CodeCopyProps;// IS CodeCopy, so diff header's copy and a code header's copy can never drift apart.export function CodeDiffCopy(props: CodeDiffCopyProps) { return <CodeCopy {...props} />;}function emptyFile(name: string | undefined): DiffFile { return { name: name ?? "file", type: "change", hunks: [], oldLines: [], newLines: [], isPartial: true, additions: 0, deletions: 0 };}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-code-diff-* · 16 knobsHow the cascade resolves--cui-code-diff-radius<length-percentage>min(
var(--radius-panel),
calc(var(--control-h-xs) / 2 + var(--cui-code-diff-border-width) + var(--_code-corner-inset))
)--cui-code-diff-shadow*var(--shadow-sm)--cui-code-diff-background<color>var(--background)--cui-code-diff-border-color<color>var(--border)--cui-code-diff-border-width<length>var(--control-rim-width)--cui-code-diff-header-background<color>var(--diff-gutter-bg)--cui-code-diff-header-border-color<color>var(--border)--cui-code-diff-foreground<color>var(--code-foreground)--cui-code-diff-add-background<color>var(--diff-add-line)--cui-code-diff-del-background<color>var(--diff-del-line)--cui-code-diff-expand-button-radius<length-percentage>var(--radius-control)--cui-code-diff-expand-button-background<color>transparent--cui-code-diff-expand-button-foreground<color>var(--muted-foreground)--cui-code-diff-expand-button-shadow*none--cui-code-diff-expand-button-hover-background<color>oklch(from var(--foreground) l c h / 0.08)--cui-code-diff-expand-button-hover-foreground<color>var(--foreground)