Skip to content

Instantly share code, notes, and snippets.

@axhamre
Created June 23, 2026 14:44
Show Gist options
  • Select an option

  • Save axhamre/1087a343ba4754fc0be0e6041c4258e1 to your computer and use it in GitHub Desktop.

Select an option

Save axhamre/1087a343ba4754fc0be0e6041c4258e1 to your computer and use it in GitHub Desktop.
Reusable HTML diff template
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reusable text diff template</title>
<style>
:root {
--bg:#f7f7f4; --paper:#ffffff; --ink:#17202a; --muted:#667085; --line:#d0d5dd;
--add:#d8f5df; --add-border:#56b870; --del:#ffe0e0; --del-border:#e15b64; --chg:#fff2b8; --chg-border:#e0b400;
}
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; background:var(--bg); color:var(--ink); }
header { position:sticky; top:0; z-index:10; background:rgba(255,255,255,.96); border-bottom:1px solid var(--line); padding:18px 24px 12px; backdrop-filter: blur(6px); }
h1 { font-size:22px; margin:0 0 8px; }
.meta { display:flex; gap:16px; flex-wrap:wrap; color:var(--muted); font-size:13px; }
.badge { border:1px solid var(--line); border-radius:999px; padding:4px 9px; background:#fff; }
.legend { display:flex; gap:10px; flex-wrap:wrap; margin-top:10px; font-size:13px; }
.key { padding:4px 8px; border-radius:6px; border:1px solid var(--line); }
.key.add { background:var(--add); border-color:var(--add-border); }
.key.del { background:var(--del); border-color:var(--del-border); }
.key.chg { background:var(--chg); border-color:var(--chg-border); }
.canvas-wrap { height:130px; position:relative; background:#fbfbfa; border-top:1px solid #eee; margin-top:12px; border-radius:10px; overflow:hidden; }
#diffCanvas { width:100%; height:130px; display:block; }
main { max-width:1500px; margin:18px auto 60px; padding:0 18px; position:relative; }
.columns { display:grid; grid-template-columns:1fr 1fr; gap:14px; position:sticky; top:126px; z-index:5; margin-bottom:8px; }
.col-title { background:#111827; color:#fff; padding:10px 14px; border-radius:10px; font-weight:700; }
.diff { background:var(--paper); border:1px solid var(--line); border-radius:14px; overflow:hidden; box-shadow:0 1px 2px rgba(16,24,40,.05); }
.diff-row { display:grid; grid-template-columns:1fr 1fr; min-height:38px; border-top:1px solid #eef0f3; scroll-margin-top:190px; }
.diff-row:first-child { border-top:0; }
.pane { padding:10px 14px; line-height:1.48; font-size:14.5px; white-space:pre-wrap; overflow-wrap:anywhere; }
.pane.old { border-right:1px solid #eef0f3; }
.diff-row.equal .block { color:#30363d; }
.diff-row.insert .new { background:linear-gradient(90deg, rgba(86,184,112,.16), transparent 72%); }
.diff-row.delete .old { background:linear-gradient(90deg, rgba(225,91,100,.16), transparent 72%); }
.diff-row.replace .old, .diff-row.replace .new { background:linear-gradient(90deg, rgba(224,180,0,.13), transparent 72%); }
.add-word { background:var(--add); border-bottom:2px solid var(--add-border); border-radius:3px; padding:0 2px; }
.del-word { background:var(--del); border-bottom:2px solid var(--del-border); border-radius:3px; padding:0 2px; text-decoration: line-through; text-decoration-thickness:1px; }
.diff-row.replace .add-word, .diff-row.replace .del-word { background:var(--chg); border-bottom-color:var(--chg-border); text-decoration:none; }
.diff-row[data-change-id] { cursor:pointer; }
.diff-row[data-change-id]:hover { outline:2px solid #98a2b3; outline-offset:-2px; }
.block:empty::after { content:'∅'; color:#98a2b3; }
footer { max-width:1500px; margin:0 auto 40px; padding:0 18px; color:var(--muted); font-size:12px; }
textarea.source { display:none; }
@media (max-width:900px) { .columns,.diff-row { grid-template-columns:1fr; } .pane.old { border-right:0; border-bottom:1px solid #eef0f3; } .columns { display:none; } }
</style>
</head>
<body>
<!--
How to reuse:
1. Paste your original text between <textarea id="oldText"> and </textarea>.
2. Paste your revised text between <textarea id="newText"> and </textarea>.
3. Optionally edit DIFF_TITLE, OLD_LABEL, and NEW_LABEL below.
4. Open this HTML file in a browser.
Tip: Do not escape HTML characters in your pasted text. Paste normal text as-is.
-->
<textarea id="oldText" class="source">
PASTE ORIGINAL TEXT HERE
</textarea>
<textarea id="newText" class="source">
PASTE REVISED TEXT HERE
</textarea>
<script>
const DIFF_TITLE = "Reusable text diff";
const OLD_LABEL = "Original";
const NEW_LABEL = "Revised";
</script>
<header>
<h1 id="title">Reusable text diff</h1>
<div class="meta">
<span class="badge" id="oldBadge">Old: original text</span>
<span class="badge" id="newBadge">New: revised text</span>
<span class="badge" id="changedBadge">Changed blocks: 0</span>
<span class="badge" id="addedBadge">Added blocks: 0</span>
<span class="badge" id="deletedBadge">Deleted blocks: 0</span>
</div>
<div class="legend">
<span class="key add">Green = added</span>
<span class="key del">Red = deleted</span>
<span class="key chg">Yellow = modified wording</span>
</div>
<div class="canvas-wrap" aria-label="Canvas diff overview"><canvas id="diffCanvas"></canvas></div>
</header>
<main>
<div class="columns"><div class="col-title" id="oldTitle">Original</div><div class="col-title" id="newTitle">Revised</div></div>
<section class="diff" id="diff"></section>
</main>
<footer>
Canvas guide: each vertical mark represents a changed block in document order. Click a mark or a highlighted row to navigate. Curved connectors show old-to-new paired changes where both sides exist.
</footer>
<script>
function escapeHtml(str) {
return str.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function tokenize(str) {
return str.match(/\w+|[^\w\s]+|\s+/gu) || [];
}
function splitBlocks(text) {
return text.trim().split(/\n\s*\n/g).flatMap(raw => {
const lines = raw.trim().split('\n').map(s => s.trim()).filter(Boolean);
return lines.length > 1 ? lines : lines;
}).filter(Boolean);
}
function sequenceMatcher(a, b) {
// Small, dependency-free LCS matcher. Good for text/docs; very large files may be slower.
const n = a.length, m = b.length;
const dp = Array.from({length:n+1}, () => new Uint32Array(m+1));
for (let i=n-1; i>=0; i--) for (let j=m-1; j>=0; j--) dp[i][j] = a[i] === b[j] ? dp[i+1][j+1] + 1 : Math.max(dp[i+1][j], dp[i][j+1]);
const ops = [];
let i=0, j=0;
function push(tag, ai, bj) {
const last = ops[ops.length-1];
if (last && last.tag === tag) { last.a.push(ai); last.b.push(bj); }
else ops.push({tag, a:[ai], b:[bj]});
}
while (i<n && j<m) {
if (a[i] === b[j]) { push('equal', i++, j++); }
else if (dp[i+1][j] >= dp[i][j+1]) { push('delete', i++, null); }
else { push('insert', null, j++); }
}
while (i<n) push('delete', i++, null);
while (j<m) push('insert', null, j++);
// Coalesce adjacent delete+insert or insert+delete into replace.
const out = [];
for (let k=0; k<ops.length; k++) {
const cur = ops[k], next = ops[k+1];
if (next && ((cur.tag === 'delete' && next.tag === 'insert') || (cur.tag === 'insert' && next.tag === 'delete'))) {
out.push({tag:'replace', a:cur.a.concat(next.a).filter(x => x !== null), b:cur.b.concat(next.b).filter(x => x !== null)});
k++;
} else out.push(cur);
}
return out;
}
function wordDiff(a, b, side) {
const at = tokenize(a), bt = tokenize(b);
const ops = sequenceMatcher(at, bt);
let out = '';
for (const op of ops) {
if (op.tag === 'equal') out += escapeHtml((side === 'old' ? op.a.map(i => at[i]) : op.b.map(i => bt[i])).join(''));
else if ((op.tag === 'delete' || op.tag === 'replace') && side === 'old') out += `<span class="del-word">${escapeHtml(op.a.map(i => at[i]).join(''))}</span>`;
else if ((op.tag === 'insert' || op.tag === 'replace') && side === 'new') out += `<span class="add-word">${escapeHtml(op.b.map(i => bt[i]).join(''))}</span>`;
}
return out;
}
function buildDiff() {
document.getElementById('title').textContent = DIFF_TITLE;
document.getElementById('oldTitle').textContent = OLD_LABEL;
document.getElementById('newTitle').textContent = NEW_LABEL;
document.getElementById('oldBadge').textContent = `Old: ${OLD_LABEL.toLowerCase()}`;
document.getElementById('newBadge').textContent = `New: ${NEW_LABEL.toLowerCase()}`;
const oldBlocks = splitBlocks(document.getElementById('oldText').value);
const newBlocks = splitBlocks(document.getElementById('newText').value);
const ops = sequenceMatcher(oldBlocks, newBlocks);
const rows = [];
const stats = {added:0, deleted:0, changed:0, unchanged:0};
let changeId = 0;
for (const op of ops) {
if (op.tag === 'equal') {
for (let k=0; k<op.a.length; k++) { stats.unchanged++; rows.push({type:'equal', cid:null, oldHtml:escapeHtml(oldBlocks[op.a[k]]), newHtml:escapeHtml(newBlocks[op.b[k]])}); }
} else if (op.tag === 'delete') {
for (const i of op.a) { changeId++; stats.deleted++; rows.push({type:'delete', cid:changeId, oldHtml:`<span class="del-word">${escapeHtml(oldBlocks[i])}</span>`, newHtml:''}); }
} else if (op.tag === 'insert') {
for (const j of op.b) { changeId++; stats.added++; rows.push({type:'insert', cid:changeId, oldHtml:'', newHtml:`<span class="add-word">${escapeHtml(newBlocks[j])}</span>`}); }
} else if (op.tag === 'replace') {
const len = Math.max(op.a.length, op.b.length);
for (let k=0; k<len; k++) {
const oldVal = op.a[k] != null ? oldBlocks[op.a[k]] : '';
const newVal = op.b[k] != null ? newBlocks[op.b[k]] : '';
changeId++;
if (oldVal && newVal) { stats.changed++; rows.push({type:'replace', cid:changeId, oldHtml:wordDiff(oldVal, newVal, 'old'), newHtml:wordDiff(oldVal, newVal, 'new')}); }
else if (oldVal) { stats.deleted++; rows.push({type:'delete', cid:changeId, oldHtml:`<span class="del-word">${escapeHtml(oldVal)}</span>`, newHtml:''}); }
else { stats.added++; rows.push({type:'insert', cid:changeId, oldHtml:'', newHtml:`<span class="add-word">${escapeHtml(newVal)}</span>`}); }
}
}
}
document.getElementById('changedBadge').textContent = `Changed blocks: ${stats.changed}`;
document.getElementById('addedBadge').textContent = `Added blocks: ${stats.added}`;
document.getElementById('deletedBadge').textContent = `Deleted blocks: ${stats.deleted}`;
document.getElementById('diff').innerHTML = rows.map(r => `
<div class="diff-row ${r.type}"${r.cid ? ` data-change-id="${r.cid}"` : ''}>
<div class="pane old"><div class="block">${r.oldHtml}</div></div>
<div class="pane new"><div class="block">${r.newHtml}</div></div>
</div>`).join('');
setupCanvas();
}
function setupCanvas() {
const canvas = document.getElementById('diffCanvas');
const ctx = canvas.getContext('2d');
const rows = Array.from(document.querySelectorAll('.diff-row[data-change-id]'));
function typeColor(row) {
if (row.classList.contains('insert')) return '#56b870';
if (row.classList.contains('delete')) return '#e15b64';
return '#e0b400';
}
function draw() {
const w = canvas.clientWidth, h = canvas.clientHeight;
ctx.clearRect(0,0,w,h);
ctx.font = '12px system-ui, sans-serif';
ctx.fillStyle = '#667085';
ctx.fillText('Diff canvas overview - click markers to jump', 16, 20);
const baseY = 74;
ctx.strokeStyle = '#d0d5dd'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(24, baseY); ctx.lineTo(w-24, baseY); ctx.stroke();
const n = rows.length || 1;
rows.forEach((row, idx) => {
const x = 24 + (w-48) * (idx/(Math.max(n-1,1)));
const color = typeColor(row);
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 3;
const top = row.classList.contains('insert') ? baseY - 34 : baseY - 18;
const bottom = row.classList.contains('delete') ? baseY + 34 : baseY + 18;
ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, bottom); ctx.stroke();
if (row.classList.contains('replace')) {
ctx.globalAlpha = .35;
ctx.beginPath(); ctx.moveTo(x-8, baseY+26); ctx.bezierCurveTo(x-4, baseY+42, x+4, baseY-42, x+8, baseY-26); ctx.stroke();
ctx.globalAlpha = 1;
}
});
const progress = Math.min(1, Math.max(0, scrollY / (document.body.scrollHeight - innerHeight || 1)));
const px = 24 + (w-48) * progress;
ctx.strokeStyle = '#111827'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(px, 36); ctx.lineTo(px, h-18); ctx.stroke();
}
function resize() {
const r = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(r.width * devicePixelRatio));
canvas.height = Math.max(1, Math.floor(r.height * devicePixelRatio));
ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0);
draw();
}
canvas.onclick = e => {
const rect = canvas.getBoundingClientRect();
const n = rows.length || 1;
const idx = Math.max(0, Math.min(n-1, Math.round((e.clientX - rect.left - 24)/(rect.width-48) * (n-1))));
rows[idx]?.scrollIntoView({behavior:'smooth', block:'center'});
};
rows.forEach(row => row.onclick = () => row.scrollIntoView({behavior:'smooth', block:'center'}));
window.onresize = resize;
window.onscroll = draw;
resize();
}
buildDiff();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment