A high-performance React component for rendering GitHub-style code diffs with syntax highlighting, inline word-level diff, in-diff search, collapsible context, resizable split view, and full theming support.
Built on diff for diff computation and refractor (Prism-based) for syntax highlighting.
- Split & Unified views �GitHub-style side-by-side or unified diff layout
- Syntax highlighting �40+ languages via refractor (Prism), token colors fully themeable
- Inline word-level diff �highlights the exact words/characters that changed within a modified line
- In-diff search �debounced search with match navigation, case-sensitivity toggle, and auto-scroll
- Collapsible context �GitHub-style "show N hidden lines" folding with configurable context lines
- Change navigation �jump between changed blocks with prev/next buttons
- Resizable split �drag the divider to adjust left/right ratio (direct DOM, no re-renders during drag)
- Light & Dark themes �GitHub Primer-inspired palettes, switch via a single prop
- Fully configurable �deep-mergeable config covering colors, fonts, layout, icons, texts, toolbar, and diff behavior
- Custom toolbar �render your own toolbar via the
renderToolbarprop - Copy to clipboard �one-click copy of old/new code
- Large file protection �auto-disables inline diff / highlighting above configurable char/line limits
- No trailing newline indicator �shows
\ No newline at end of filewhen appropriate - TypeScript �fully typed, ships types out of the box
Add a screenshot or GIF here after first release.
npm install @jiang_quan_ming/react-code-diff
# or
pnpm add @jiang_quan_ming/react-code-diff
# or
yarn add @jiang_quan_ming/react-code-diffThen import the component and its styles:
import { CodeDiff } from '@jiang_quan_ming/react-code-diff'
import '@jiang_quan_ming/react-code-diff/style.css'Peer dependencies: react >= 18, react-dom >= 18.
import { useState } from 'react'
import { CodeDiff } from '@jiang_quan_ming/react-code-diff'
import '@jiang_quan_ming/react-code-diff/style.css'
const OLD = `function greet(name) {
return 'Hello, ' + name
}`
const NEW = `function greet(name: string) {
return \`Hello, \${name}!\`
}`
export default function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
return (
<CodeDiff
oldValue={OLD}
newValue={NEW}
language="tsx"
fileName="greet.ts"
viewMode="split" // 'split' | 'unified'
theme={theme} // 'dark' | 'light'
showDiffOnly // collapse unchanged context
contextLines={3} // lines of context around changes
wrapLines={false} // soft-wrap long lines
highlightInlineChanges // word-level inline diff
maxHeight="70vh"
/>
)
}// Side-by-side (default) �drag the divider to resize
<CodeDiff oldValue={old} newValue={new} viewMode="split" />
// Unified �old and new stacked in a single column
<CodeDiff oldValue={old} newValue={new} viewMode="unified" />Pass theme directly, or wire it to state for a toggle:
function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
return (
<>
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle theme
</button>
<CodeDiff oldValue={old} newValue={new} theme={theme} />
</>
)
}showDiffOnly collapses long unchanged sections to a "show N hidden lines" bar, keeping only changes and contextLines lines around them:
<CodeDiff
oldValue={old}
newValue={new}
showDiffOnly // collapse unchanged context (default: true)
contextLines={5} // show 5 lines of context around each change
/>When highlightInlineChanges is on (default), modified lines highlight the exact words that changed:
<CodeDiff
oldValue={old}
newValue={new}
highlightInlineChanges // highlight added/removed words within a line
wrapLines // soft-wrap long lines instead of horizontal scroll
/>Track the divider ratio in state to persist it across renders or sync multiple views:
const [ratio, setRatio] = useState(0.5)
<CodeDiff
oldValue={old}
newValue={new}
viewMode="split"
splitRatio={ratio}
onSplitRatioChange={setRatio} // called on drag end
resizableSplit // enable dragging (default: true)
/>The built-in search bar (toggle via the search icon in the toolbar) supports case-sensitive matching and prev/next navigation. Listen to match changes:
<CodeDiff
oldValue={old}
newValue={new}
onSearchMatchChange={(match, total) => {
console.log(`${match ? match.rowIndex : 'none'} / ${total} matches`)
}}
/>The toolbar ships copy buttons for old and new code. Intercept them to use a custom clipboard or track analytics:
<CodeDiff
oldValue={old}
newValue={new}
onCopy={(which, text) => {
// return false to prevent the default clipboard write
// return true to skip it (you handled it)
trackCopy(which)
}}
/>Replace the entire toolbar with your own via renderToolbar:
import type { ToolbarRenderProps } from '@jiang_quan_ming/react-code-diff'
<CodeDiff
oldValue={old}
newValue={new}
renderToolbar={(props: ToolbarRenderProps) => (
<div className="my-toolbar">
<span>{props.fileName}</span>
<span>+{props.stats.additions} -{props.stats.deletions}</span>
<button onClick={() => props.onCopy('new')}>Copy new</button>
</div>
)}
/>ToolbarRenderProps exposes fileName, language, stats, searchOpen, onToggleSearch, onCopy, copied, changeCount, onNavigateChange, and config.
<CodeDiff
oldValue={old}
newValue={new}
onLineClick={(row, index) => {
console.log('Clicked line', index, row.type)
}}
/>Get the addition/deletion counts after diff computation:
<CodeDiff
oldValue={old}
newValue={new}
onDiffComputed={(stats) => {
console.log(`${stats.additions} additions, ${stats.deletions} deletions`)
}}
/>| Prop | Type | Default | Description |
|---|---|---|---|
oldValue |
string |
� | Old code (required) |
newValue |
string |
� | New code (required) |
language |
string |
'typescript' |
Language for syntax highlighting (aliases supported, e.g. ts, js, py) |
fileName |
string |
� | File name shown in the toolbar |
viewMode |
'split' | 'unified' |
'split' |
Diff layout mode |
theme |
'light' | 'dark' |
'dark' |
Color theme |
showLineNumbers |
boolean |
true |
Show line number gutter |
showToolbar |
boolean |
true |
Show the built-in toolbar |
showDiffOnly |
boolean |
true |
Collapse unchanged context to only show changes + N context lines |
contextLines |
number |
3 |
Context lines around each change block (used when showDiffOnly) |
wrapLines |
boolean |
false |
Soft-wrap long lines |
highlightInlineChanges |
boolean |
true |
Enable word-level inline diff on modified lines |
maxHeight |
number | string |
� | Max height of the scroll area (e.g. 500, '70vh') |
resizableSplit |
boolean |
true |
Enable drag-to-resize in split mode |
splitRatio |
number |
0.5 |
Initial left/right ratio (0�), controlled |
onSplitRatioChange |
(ratio: number) => void |
� | Called when the user finishes dragging the divider |
config |
PartialConfig |
� | Deep-merged over the default config (see below) |
renderToolbar |
(props: ToolbarRenderProps) => ReactNode |
� | Custom toolbar renderer |
onCopy |
(which, text) => void | boolean |
� | Intercept copy; return false to prevent, true to skip default |
onLineClick |
(row, index) => void |
� | Called when a diff row is clicked |
onSearchMatchChange |
(match, total) => void |
� | Called when the active search match changes |
onDiffComputed |
(stats) => void |
� | Called with { additions, deletions } after diff is computed |
className |
string |
� | Extra class on the root element |
style |
React.CSSProperties |
� | Inline styles on the root element |
Pass a config prop to override any part of the default config. Overrides are deep-merged, so you only need to specify what you want to change.
import { CodeDiff, mergeConfig } from '@jiang_quan_ming/react-code-diff'
const config = mergeConfig({
font: {
family: "'Fira Code', monospace",
size: 14,
},
colors: {
dark: {
addedBg: 'rgba(46, 160, 67, 0.25)',
},
},
toolbar: {
showLanguage: false,
},
})
<CodeDiff oldValue={OLD} newValue={NEW} config={config} />interface CodeDiffConfig {
font: FontConfig // family, size, lineHeight
layout: LayoutConfig // borderRadius, gutterMinWidth, ...
diff: DiffConfig // inlineDiffLineLimit, char limits, ignoreWhitespace
search: SearchConfig // debounceMs, maxResults
toolbar: ToolbarConfig // show flags for each toolbar element
icons: IconsConfig // Lucide icons, replaceable
texts: TextsConfig // UI strings (i18n-friendly)
colors: {
dark: ThemeColors
light: ThemeColors
}
}Each color object covers backgrounds, added/removed colors, gutter, search highlight, collapse, toolbar, accent, and a full set of syntax token colors �all exposed as CSS variables at runtime.
Two built-in themes ship out of the box, both GitHub-Primer-inspired:
theme="dark"�dark background (#0d1117)theme="light"�light background (#ffffff)
Switching is zero-JS-cost: the component sets CSS variables on the root element, so theme changes never re-render rows.
To customize colors, override the colors.dark / colors.light entries in config.
pnpm test # run all unit tests (vitest)
pnpm test:watch # watch mode
pnpm lint # oxlint
pnpm build # tsc + vite buildThe three core engines -- diff-engine, highlight-engine, and segment-merger -- have full unit test coverage (78 tests) covering edge cases: empty strings, no trailing newline, CRLF, inline diff limits, context folding/expansion, search case sensitivity, token inheritance across lines, and three-way range intersection.
This project uses a separate Vite library-mode config (vite.lib.config.ts) to build the publishable bundle. The demo app (vite.config.ts) is for development only.
pnpm build:libThis runs vite build --config vite.lib.config.ts, which produces:
dist/index.js-- ESM bundle (react/react-dom/diff/lucide-react/refractor externalized)dist/style.css-- component stylesdist/*.d.ts-- TypeScript type declarations (viavite-plugin-dts)
npm pack --dry-runConfirm the tarball only contains dist/, README.md, LICENSE, and package.json -- no source, demo, or node_modules.
# Bump version in package.json first (npm version patch/minor/major)
# Publish to the official npm registry
npm publish --registry https://registry.npmjs.org/ --access publicNote: If your default registry is set to a mirror (e.g. npmmirror), you must pass
--registry https://registry.npmjs.org/explicitly.
2FA: If your npm account has two-factor authentication enabled, either pass
--otp <code>with a 6-digit code from your authenticator app, or create a Granular Access Token with "Bypass 2FA" enabled at npmjs.com/settings -> Access Tokens -> Generate New Token.
npm version patch # 1.0.0 -> 1.0.1
npm version minor # 1.0.0 -> 1.1.0
npm version major # 1.0.0 -> 2.0.0Then pnpm build:lib && npm publish --registry https://registry.npmjs.org/ --access public.
Syntax highlighting uses refractor, which bundles Prism grammars. Common aliases work out of the box: js/javascript, ts/typescript, jsx, tsx, py/python, go, rs/rust, java, json, css, scss, sql, yaml, toml, md/markdown, bash/sh, c#/cs, c++, html/xml, docker, and more.
Unsupported or unknown languages fall back to plain text (no crash).
MIT © quanming1