Code Diff Reveal
A change plays out like a code review. Old lines turn red and a line strikes through them, then the new lines slide in green beneath, while the + and − counts in the header tick up.
export const motion = {
duration: 400,
ease: 'linear',
duration: 800,
ease: 'outQuart',
loop: true,
};
How to use it
- Paste the snippet just before
</head>. - Add
data-code-diff-revealto a container with one element per line. Mark removed lines withdata-deland added lines withdata-add; the rest are unchanged context.
Options
Add any of these attributes to the same element.
data-file | The file name in the header. |
data-speed | Speed multiplier. Default 1; 2 is twice as fast. |
data-loop | Undo the change and play it again after a pause. |
data-hold | How long the finished diff stays before looping. Default 2600 ms. |
Several separate changes in one file play in order, top to bottom. Screen readers hear “removed” or “added” before each changed line.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Code Diff Reveal: a code review plays out. Removed lines turn red and are
// struck through, then the new lines slide in green beneath them, while the
// +/- counts in the header tick up.
// Usage:
// <div data-code-diff-reveal data-file="src/config.js">
// <p>export default {</p>
// <p data-del> duration: 400,</p>
// <p data-add> duration: 800,</p>
// <p>};</p>
// </div>
// One element per line; data-del marks a removed line, data-add an added one,
// anything else is unchanged context.
// Optional: data-file (file name in the header), data-speed (multiplier,
// default 1; 2 is twice as fast), data-loop (undo and replay), data-hold (ms
// the finished diff stays before looping, default 2600).
var DEFAULTS = { speed: 1, hold: 2600 };
var css =
'.cdx{margin:0;text-align:left;background:#fff;color:#24292f;border:1px solid #d0d7de;border-radius:10px;overflow:hidden;' +
'font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;box-shadow:0 14px 34px rgba(31,35,40,.1)}' +
'.cdx-head{display:flex;align-items:center;gap:0.8em;padding:0.6em 0.9em;background:#f6f8fa;border-bottom:1px solid #d0d7de;font-size:0.9em}' +
'.cdx-file{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}' +
'.cdx-stat{flex:none;font-weight:700;font-variant-numeric:tabular-nums}' +
'.cdx-stat b:first-child{color:#1a7f37;margin-right:0.5em}.cdx-stat b:last-child{color:#cf222e}' +
'.cdx-body{padding:0.35em 0}' +
'.cdx-line{display:flex;line-height:1.65;overflow:hidden;transition:background-color .3s}' +
'.cdx-ln{flex:none;width:2.2em;padding-right:0.6em;text-align:right;color:#8c959f;font-size:0.9em;user-select:none;transition:background-color .3s}' +
'.cdx-sign{flex:none;width:1.4em;text-align:center;font-weight:700;user-select:none}' +
'.cdx-code{position:relative;white-space:pre;padding-right:1em;min-width:0}' +
'.cdx-strike{position:absolute;left:0;right:1em;top:52%;height:2px;border-radius:2px;background:#cf222e;transform:scaleX(0);transform-origin:0 50%}' +
'.cdx-line.cdx-on.cdx-del{background:#ffebe9}' +
'.cdx-line.cdx-on.cdx-del .cdx-ln{background:#ffd7d5}' +
'.cdx-line.cdx-on.cdx-del .cdx-sign{color:#cf222e}' +
'.cdx-line.cdx-on.cdx-del .cdx-txt{color:#82071e;opacity:.7;transition:opacity .3s}' +
'.cdx-line.cdx-add{background:#dafbe1}' +
'.cdx-line.cdx-add .cdx-ln{background:#aceebb}' +
'.cdx-line.cdx-add .cdx-sign{color:#1a7f37}' +
'.cdx-flash{animation:cdx-flash .9s ease-out}' +
'@keyframes cdx-flash{0%{box-shadow:inset 3px 0 0 #1a7f37;background:#aceebb}100%{box-shadow:inset 3px 0 0 transparent}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function setup(el) {
var d = el.dataset;
var src = Array.from(el.children);
el.classList.add('cdx');
var head = document.createElement('div');
head.className = 'cdx-head';
head.innerHTML = '<span class="cdx-file"></span><span class="cdx-stat" aria-hidden="true"><b>+0</b><b>−0</b></span>';
head.firstChild.textContent = d.file || 'diff';
var body = document.createElement('div');
body.className = 'cdx-body';
var oldN = 0, newN = 0;
var lines = src.map(function (p) {
var kind = p.hasAttribute('data-del') ? 'del' : p.hasAttribute('data-add') ? 'add' : 'ctx';
var text = p.textContent.replace(/\s+$/, '');
var row = document.createElement('div');
row.className = 'cdx-line cdx-' + kind;
var n = kind === 'add' ? ++newN : ++oldN;
if (kind === 'ctx') newN++;
row.innerHTML = '<span class="cdx-ln" aria-hidden="true"></span><span class="cdx-sign" aria-hidden="true"></span>' +
'<span class="cdx-code"><span class="cdx-txt"></span><i class="cdx-strike" aria-hidden="true"></i></span>';
row.firstChild.textContent = n;
row.querySelector('.cdx-txt').textContent = text;
if (kind !== 'ctx') {
// Screen readers hear "removed:" / "added:" before the line.
var sr = document.createElement('span');
sr.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)';
sr.textContent = kind === 'del' ? 'removed: ' : 'added: ';
row.querySelector('.cdx-code').insertBefore(sr, row.querySelector('.cdx-txt'));
}
body.appendChild(row);
return { row: row, kind: kind, strike: row.querySelector('.cdx-strike'), sign: row.children[1] };
});
el.textContent = '';
el.appendChild(head);
el.appendChild(body);
// Group lines into hunks: a run of removals followed by its additions.
var hunks = [], cur = null;
lines.forEach(function (l) {
if (l.kind === 'ctx') { cur = null; return; }
if (!cur || (l.kind === 'del' && cur.add.length)) { cur = { del: [], add: [] }; hunks.push(cur); }
cur[l.kind].push(l);
});
return {
el: el, body: body, lines: lines, hunks: hunks, plus: head.querySelector('b'), minus: head.querySelector('b:last-child'),
nAdd: lines.filter(function (l) { return l.kind === 'add'; }).length,
nDel: lines.filter(function (l) { return l.kind === 'del'; }).length,
visible: false, wake: null
};
}
function setCounts(s, a, r) { s.plus.textContent = '+' + a; s.minus.textContent = '−' + r; }
function finished(s) {
s.lines.forEach(function (l) {
if (l.kind === 'del') { l.row.classList.add('cdx-on'); l.sign.textContent = '−'; anime.utils.set(l.strike, { scaleX: 1 }); }
if (l.kind === 'add') { l.row.style.height = ''; l.row.style.opacity = ''; anime.utils.set(l.row, { x: 0 }); l.sign.textContent = '+'; }
});
setCounts(s, s.nAdd, s.nDel);
}
function start(s) {
s.lines.forEach(function (l) {
l.row.classList.remove('cdx-on', 'cdx-flash');
if (l.kind === 'del') { l.sign.textContent = ''; anime.utils.set(l.strike, { scaleX: 0 }); }
if (l.kind === 'add') { l.row.style.height = '0px'; l.row.style.opacity = '0'; }
});
setCounts(s, 0, 0);
}
function whenVisible(s) {
return s.visible ? Promise.resolve() : new Promise(function (r) { s.wake = r; });
}
function play(s) {
var d = s.el.dataset;
var k = 1 / (+d.speed || DEFAULTS.speed);
var added = 0, removed = 0;
start(s);
var chain = sleep(500 * k);
s.hunks.forEach(function (h) {
h.del.forEach(function (l) {
chain = chain.then(function () {
l.row.classList.add('cdx-on');
l.sign.textContent = '−';
setCounts(s, added, ++removed);
anime.animate(l.strike, { scaleX: [0, 1], duration: 420 * k, ease: 'inOut(2)' });
return sleep(260 * k);
});
});
chain = chain.then(function () { return sleep(280 * k); });
h.add.forEach(function (l) {
chain = chain.then(function () {
var full = l.row.scrollHeight;
l.sign.textContent = '+';
l.row.classList.add('cdx-flash');
setCounts(s, ++added, removed);
anime.animate(l.row, {
height: [0, full], opacity: [0, 1], x: [-18, 0], duration: 460 * k, ease: 'out(3)',
onComplete: function () { l.row.style.height = ''; }
});
return sleep(240 * k);
});
});
chain = chain.then(function () { return sleep(500 * k); });
});
chain.then(function () {
if (d.loop === undefined || d.loop === 'false') return;
sleep(+d.hold || DEFAULTS.hold).then(function () { return whenVisible(s); }).then(function () {
var adds = s.lines.filter(function (l) { return l.kind === 'add'; }).map(function (l) { return l.row; });
var strikes = s.lines.filter(function (l) { return l.kind === 'del'; }).map(function (l) { return l.strike; });
if (strikes.length) anime.animate(strikes, { scaleX: 0, duration: 300, ease: 'in(2)' });
s.lines.forEach(function (l) { if (l.kind === 'del') l.row.classList.remove('cdx-on'); });
if (!adds.length) return sleep(400);
return new Promise(function (r) {
anime.animate(adds, { height: 0, opacity: 0, duration: 380, ease: 'inOut(2)', onComplete: r });
});
}).then(function () { play(s); });
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._cdx;
s.visible = e.isIntersecting;
if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-code-diff-reveal]').forEach(function (el) {
if (el._cdx) return;
var s = (el._cdx = setup(el));
finished(s);
if (reduced()) return;
// Reserve the finished height (in em, so it follows font-size changes).
var fs = parseFloat(getComputedStyle(s.body).fontSize) || 16;
if (s.body.offsetHeight) s.body.style.minHeight = (s.body.offsetHeight / fs) + 'em';
start(s);
io.observe(el);
whenVisible(s).then(function () { play(s); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-code-diff-reveal data-file="src/motion.js">
<p>export const motion = {</p>
<p data-del> duration: 400,</p>
<p data-add> duration: 800,</p>
<p>};</p>
</div>