A fully client-side React web app for comparing two code files or text snippets. No backend. Deployable as a static site on GitHub Pages.
- User can paste two blocks of text/code and compare them.
- User can upload two files and compare them.
- Both sides can mix paste and upload independently.
- Supports any plain-text format including
.ipynbJupyter notebooks. - Diff renders with line numbers, added/removed highlighting, and a change summary.
- Zero server dependency — everything runs in the browser.
| Tool | Version | Purpose |
|---|---|---|
| React | ^18.3.1 | UI framework |
| Vite | ^5.4.10 | Build tool + dev server |
| @vitejs/plugin-react | ^4.3.1 | JSX support in Vite |
| diff (jsdiff) | ^5.2.0 | Compute unified diff patches and line-level stats |
| diff2html | ^3.4.48 | Render unified diff as HTML (side-by-side or unified) |
No CSS framework, no router, no state management library.
project-root/
├── .github/
│ └── workflows/
│ └── deploy.yml # GitHub Actions: build + deploy to Pages
├── src/
│ ├── components/
│ │ ├── InputPane.jsx # One input panel (paste tab + upload tab)
│ │ └── DiffViewer.jsx # Renders the diff output
│ ├── utils/
│ │ └── parseFile.js # Reads File objects → plain text string
│ ├── App.jsx # Root component, all state lives here
│ ├── App.css # All styles (single CSS file, no modules)
│ └── main.jsx # React entry point
├── index.html
├── package.json
└── vite.config.js
There are two input panes side by side: Original (left) and Modified (right).
Each pane has two tabs:
Paste tab
- Monospace
<textarea>withspellCheck={false}. - Placeholder:
"Paste original code here..."/"Paste modified code here...". - User can type or paste any text.
Upload tab
- A dashed-border drop zone that accepts drag-and-drop or click-to-browse.
- Clicking the zone opens a hidden
<input type="file">. - Dragging a file over the zone changes its border and background color (visual feedback).
- After a file is loaded:
- Show the file name and a file icon.
- Show two buttons: Change (re-opens file picker) and Remove (clears the pane).
- Do NOT switch back to the Paste tab — stay on Upload tab showing the loaded file.
- The parsed file text is fed into the same state as the paste textarea (shared
value).
A narrow vertical button between the two panes with a swap/arrows icon.
- Clicking it swaps left content ↔ right content AND left filename ↔ right filename.
- Resets
comparedstate tofalse(hides the old diff output).
A horizontal bar below the input section containing:
Left side:
- Compare button — primary action, indigo/purple, disabled when both sides are empty.
- Clear button — ghost style, disabled when both sides are empty. Resets all state.
Right side:
- Diff stats badge — shown only after Compare is clicked. Displays
+N(green) and-N(red) line counts. Label: "lines changed". - View toggle — two buttons: "Side by Side" and "Unified". Switching is instant; does NOT require re-clicking Compare.
Shown only after Compare is clicked. Hidden (replaced by empty state) before first Compare.
- Uses
diff2htmlto render the diff. drawFileList: false— do not show the file list header.matching: 'lines'— match lines for better diff quality.outputFormat:'side-by-side'or'line-by-line'based on the view toggle.- The diff is computed from a snapshot taken at the moment Compare is clicked, not live. Editing the textarea after Compare does not update the diff until Compare is clicked again.
- The
fileNamepassed tocreatePatchis the uploaded file name if one exists, otherwise'file'.
When no diff has been run yet, show a centered placeholder with:
- A faint SVG icon (diff-like visual).
- Text:
Paste code or upload files above, then click Compare.
Full-width footer at the bottom:
"Runs entirely in your browser — no data is sent to any server"
parseFile(file: File) → Promise<string>
- Read the file as text using
file.text(). - If
file.nameends with.ipynb: runparseNotebook(text). - Otherwise: return the raw text string.
parseNotebook(text: string) → string
- Parse JSON. If parse fails, return raw text as fallback.
- Access
nb.cellsarray (default to[]). - For each cell:
- Extract
cell.source: if it's an array, join with''; if string, use as-is; fallback to''. - Build a header:
# ── Cell N [cell_type] ──where N is 1-indexed. - Combine:
header + '\n' + source.
- Extract
- Join all cells with
'\n\n'between them.
| State variable | Type | Purpose |
|---|---|---|
left |
string | Current text in the left pane |
right |
string | Current text in the right pane |
leftFile |
string | null | Filename of the uploaded left file |
rightFile |
string | null | Filename of the uploaded right file |
viewType |
'side-by-side' | 'unified' |
Which diff view to render |
compared |
boolean | Whether a diff has been computed and shown |
diffSnapshot |
{ left, right, fileName } |
Frozen copy of content at Compare time |
Key behavior:
comparedresets tofalsewhenever eitherleftorrightcontent changes (typing or new file).comparedresets tofalseon Swap and Clear.stats(added/removed line counts) is derived viauseMemofromdiffSnapshotwhencomparedistrue.isEmptyistruewhen bothleft.trim()andright.trim()are empty strings — used to disable Compare and Clear.
| Prop | Type | Description |
|---|---|---|
label |
string | "Original" or "Modified" — shown in pane header, used in textarea placeholder |
value |
string | Controlled text value |
onChange |
(text: string) => void |
Called when text changes (paste or file upload) |
onFile |
(name: string | null) => void |
Called with filename when file is loaded, or null when removed |
Internal state: tab ('paste' | 'upload'), fileName (string | null), dragging (boolean).
| Prop | Type | Description |
|---|---|---|
left |
string | Original text (from snapshot) |
right |
string | Modified text (from snapshot) |
viewType |
string | 'side-by-side' or 'unified' |
fileName |
string | Passed to createPatch as the file name |
Implementation: uses useEffect + useRef. On every prop change, calls createPatch from the diff library, then Diff2Html.html(), and sets containerRef.current.innerHTML to the result.
Import the diff2html CSS bundle: import 'diff2html/bundles/css/diff2html.min.css'
| Role | Value |
|---|---|
| Page background | #f0f2f5 |
| Primary text | #1a1a2e |
| Header background | #1e1b4b (dark indigo) |
| Header accent border | #4f46e5 (indigo) |
| Header icon color | #818cf8 |
| Header subtitle | #a5b4fc |
| Primary button / active states | #4f46e5 |
| Primary button hover | #4338ca |
| Added lines stat | #16a34a (green) |
| Removed lines stat | #dc2626 (red) |
| Panel background | #ffffff |
| Panel border | #e2e8f0 |
| Muted text / labels | #64748b |
| Disabled / placeholder | #94a3b8 |
| Pane header background | #f8fafc |
| Drop zone background | #fafbfc |
| Drop zone hover background | #f5f3ff |
| Drop zone dragging background | #ede9fe |
- Body / UI:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif - Code textarea and diff table:
'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace - Code textarea font size:
0.82rem, line-height1.6
- Max content width:
1600px, centered withmargin: 0 auto. - Input section: two panes (
flex: 1) separated by a 40px wide swap button column. No gap between panes — borders handle separation. - Textarea minimum height:
280px,resize: vertical. - Drop zone minimum height:
280px. - Controls bar:
justify-content: space-betweenwith left group (Compare, Clear) and right group (stats badge + view toggle).
Apply these overrides on .diff-output to integrate with the app style:
- Remove wrapper margins:
.d2h-wrapper { margin: 0 } - Remove file wrapper border/radius/margin
- Style
.d2h-file-headerwith#f8fafcbackground,0.8remfont size - Use monospace font on
.d2h-diff-table - Line number cells:
color: #94a3b8,background: #f8fafcwith!important
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './', // relative base — required for GitHub Pages subdirectory hosting
})Standard Vite HTML entry. Script tag: <script type="module" src="/src/main.jsx"></script>.
"dev": "vite",
"build": "vite build",
"preview": "vite preview"Trigger: push to main branch.
Required permissions: contents: read, pages: write, id-token: write.
Steps:
actions/checkout@v4actions/setup-node@v4— Node 20, cachenpmnpm cinpm run build— outputs todist/actions/upload-pages-artifact@v3— path:distactions/deploy-pages@v4— deploys artifact
Environment name: github-pages. Concurrency group: pages, cancel-in-progress: false.
After pushing, go to: Repo → Settings → Pages → Build and deployment → Source → GitHub Actions
The app will then auto-deploy on every push to main.
- Both-empty guard: Compare and Clear buttons are disabled when both sides are empty (checked via
.trim()). - Identical content: diff2html renders a "no changes" message — this is acceptable behavior.
- Binary files: The browser's
file.text()will attempt to decode them as UTF-8. No explicit binary detection is implemented; garbled output is acceptable. - Large files: No size limit is enforced. Performance degrades gracefully in the browser.
.ipynbparse failure: If JSON parsing fails,parseNotebookreturns the raw text string as a fallback.- Changing view type (side-by-side ↔ unified) does NOT re-snapshot; it re-renders from the existing
diffSnapshot— this is intentional for instant toggling without re-clicking Compare. - Changing content after Compare:
comparedis set tofalse, hiding the old diff. The user must click Compare again to see the new diff.