← Typing

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

  1. Paste the snippet just before </head>.
  2. Add data-code-diff-reveal to a container with one element per line. Mark removed lines with data-del and added lines with data-add; the rest are unchanged context.

Options

Add any of these attributes to the same element.

data-fileThe file name in the header.
data-speedSpeed multiplier. Default 1; 2 is twice as fast.
data-loopUndo the change and play it again after a pause.
data-holdHow 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.

1 · Snippet
<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>&minus;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>
2 · Your HTML
<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>