← AI

Rewrite Diff

Text rewrites itself into a new version, word by word. Only the words that change move: the old ones are struck through in red, then fade and close up, while the new ones open up in their place with a green glow that settles into plain text. Great for “make it friendlier” and “make it shorter” buttons.

hey team, the launch is gonna slip a week cause testing found some bugs. sorry!

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-rewrite-diff to a paragraph, and list the versions to rewrite through in data-versions, separated by |.
  3. Words both versions share stay where they are; only the differences animate.

Options

Add any of these attributes to the same element.

data-versionsThe versions, separated by |. The element’s own text is used as the first if it isn’t in the list.
data-intervalHow long each version shows. Default 2400 ms.
data-durationHow long each rewrite takes. Default 1100 ms.
data-loopGo back to the first version and start again.
data-labelShow a small “Rewriting…” tag above the text while it works. Give it a value to change the words.

Call animacious.rewriteDiff(el, 'New text') to rewrite to any text, for example when your model sends back an edit.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Rewrite Diff: text rewrites itself into a new version, word by word. The
    // words that go are struck through in red, then fade and close up; the new
    // words open up in their place with a green glow that settles into plain
    // text. Words both versions share stay put.
    // Usage: <p data-rewrite-diff data-versions="First version|Second version|Third">First version</p>
    // Optional: data-versions (the versions to rewrite through, separated by |;
    // the element's own text is used as the first if left out), data-loop (go
    // back to the first version and start again), data-interval (ms each
    // version shows, default 2400), data-duration (ms each rewrite takes,
    // default 1100), data-label (show a small "Rewriting…" tag while it works;
    // give it text to change the tag).
    // Script use: animacious.rewriteDiff(el, 'New text') rewrites to any text.
    var DEFAULTS = { interval: 2400, duration: 1100, label: 'Rewriting…' };

    var css =
      '.rwd-w{display:inline-block;vertical-align:top;white-space:pre;border-radius:.25em}' +
      '.rwd-busy .rwd-w{overflow:hidden}' +
      '.rwd-del{color:#dc2626;background:rgba(220,38,38,.12);text-decoration:line-through;text-decoration-thickness:.08em}' +
      '.rwd-add{color:#15803d;background:rgba(22,163,74,.16)}' +
      '.rwd-tag{position:absolute;right:0;top:-1.9em;display:inline-flex;align-items:center;gap:.4em;padding:.15em .6em;border-radius:999px;' +
      'font-size:.62em;font-weight:600;line-height:1.5;white-space:nowrap;color:#7c3aed;background:rgba(124,58,237,.12);opacity:0;pointer-events:none}' +
      '.rwd-tag::before{content:"";width:.5em;height:.5em;border-radius:50%;background:currentColor;animation:rwd-blink 1s ease-in-out infinite}' +
      '@keyframes rwd-blink{50%{opacity:.25}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Words with the space after them, so each piece can open and close whole.
    function tokens(text) { return text.trim().replace(/\s+/g, ' ').match(/\S+\s*/g) || []; }

    // Longest common subsequence of words, walked into a list of keep/del/add.
    function diff(a, b) {
      var n = a.length, m = b.length, i, j, t = [];
      var ka = a.map(function (w) { return w.trim(); }), kb = b.map(function (w) { return w.trim(); });
      for (i = 0; i <= n; i++) { t.push(new Array(m + 1).fill(0)); }
      for (i = n - 1; i >= 0; i--) for (j = m - 1; j >= 0; j--)
        t[i][j] = ka[i] === kb[j] ? t[i + 1][j + 1] + 1 : Math.max(t[i + 1][j], t[i][j + 1]);
      var out = [];
      i = 0; j = 0;
      while (i < n || j < m) {
        if (i < n && j < m && ka[i] === kb[j]) { out.push({ op: 'keep', w: b[j] }); i++; j++; }
        else if (j < m && (i >= n || t[i][j + 1] >= t[i + 1][j])) { out.push({ op: 'add', w: b[j] }); j++; }
        else { out.push({ op: 'del', w: a[i] }); i++; }
      }
      // Show each run of removed words before the words replacing them.
      for (i = 1; i < out.length; i++) {
        if (out[i].op === 'del' && out[i - 1].op === 'add') {
          var k = i - 1;
          while (k > 0 && out[k - 1].op === 'add') k--;
          out.splice(k, 0, out.splice(i, 1)[0]);
        }
      }
      return out;
    }

    function render(el, words) {
      var st = el._rwd;
      st.box.innerHTML = '';
      words.forEach(function (w) {
        var s = document.createElement('span');
        s.className = 'rwd-w';
        s.textContent = w;
        st.box.appendChild(s);
      });
      st.words = words;
      el.setAttribute('aria-label', words.join('').trim());
    }

    function setup(el) {
      var d = el.dataset;
      var own = el.textContent.trim().replace(/\s+/g, ' ');
      var versions = d.versions ? d.versions.split('|').map(function (v) { return v.trim(); }).filter(Boolean) : [];
      if (!versions.length || (own && versions[0] !== own)) versions.unshift(own);
      var box = document.createElement('span');
      box.setAttribute('aria-hidden', 'true');
      el.textContent = '';
      el.appendChild(box);
      var tag = null;
      if (d.label !== undefined) {
        if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
        tag = document.createElement('span');
        tag.className = 'rwd-tag';
        tag.setAttribute('aria-hidden', 'true');
        tag.textContent = d.label || DEFAULTS.label;
        el.appendChild(tag);
      }
      el._rwd = { box: box, tag: tag, versions: versions, idx: 0, words: [], timer: 0, busy: false };
      render(el, tokens(versions[0]));
    }

    function rewrite(el, text, done) {
      var st = el._rwd, d = el.dataset;
      var dur = +d.duration || DEFAULTS.duration;
      var next = tokens(text);
      if (reduced()) { render(el, next); if (done) done(); return; }
      var ops = diff(st.words, next);
      st.busy = true;
      el.classList.add('rwd-busy');
      el.setAttribute('aria-label', text);
      st.box.innerHTML = '';
      var dels = [], adds = [];
      ops.forEach(function (o) {
        var s = document.createElement('span');
        s.className = 'rwd-w';
        s.textContent = o.w;
        if (o.op === 'del') dels.push(s);
        if (o.op === 'add') { s.classList.add('rwd-add'); adds.push(s); }
        st.box.appendChild(s);
      });
      adds.forEach(function (s) { s._w = s.offsetWidth; s.style.width = '0px'; s.style.opacity = '0'; });
      dels.forEach(function (s) { s._w = s.offsetWidth; s.style.transition = 'color .25s, background-color .25s'; });
      if (st.tag) anime.animate(st.tag, { opacity: 1, y: [4, 0], duration: 250, ease: 'out(2)' });
      var a = dur * 0.25, b = dur * 0.4, c = dur * 0.35;
      // 1: the old words are marked.
      var mark = Math.min(40, a / Math.max(1, dels.length));
      dels.forEach(function (s, i) { setTimeout(function () { s.classList.add('rwd-del'); }, 40 + i * mark); });
      // 2: they close up while the new words open.
      dels.forEach(function (s) {
        anime.animate(s, { width: [s._w, 0], opacity: 0, duration: b, delay: a + 80, ease: 'inOut(2)' });
      });
      var gap = Math.min(35, b / Math.max(1, adds.length));
      adds.forEach(function (s, i) {
        anime.animate(s, { width: [0, s._w], opacity: [0, 1], duration: b, delay: a + 160 + i * gap, ease: 'inOut(2)' });
      });
      // 3: the green glow settles into plain text.
      setTimeout(function () {
        if (st.tag) anime.animate(st.tag, { opacity: 0, duration: 300, ease: 'in(2)' });
        adds.forEach(function (s) { s.style.transition = 'color ' + c + 'ms, background-color ' + c + 'ms'; s.classList.remove('rwd-add'); });
        setTimeout(function () {
          render(el, next);
          st.busy = false;
          el.classList.remove('rwd-busy');
          if (done) done();
        }, c + 40);
      }, a + 260 + b + gap * adds.length);
    }

    function cycle(el) {
      var st = el._rwd, d = el.dataset;
      var interval = d.interval !== undefined ? +d.interval : DEFAULTS.interval;
      if (st.versions.length < 2) return;
      st.timer = setTimeout(function () {
        if (st.idx + 1 >= st.versions.length && d.loop === undefined) return;
        st.idx = (st.idx + 1) % st.versions.length;
        rewrite(el, st.versions[st.idx], function () { cycle(el); });
      }, interval);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          cycle(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-rewrite-diff]').forEach(function (el) {
        if (el._rwd) return;
        setup(el);
        if (reduced()) {
          var v = el._rwd.versions;
          render(el, tokens(v[v.length - 1]));
        } else io.observe(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.rewriteDiff = function (el, text) {
      if (!el || !el._rwd || el._rwd.busy) return;
      clearTimeout(el._rwd.timer);
      rewrite(el, text);
    };

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-rewrite-diff data-versions="Hey, can we push the meeting to Friday?|Hi Sam, could we move our meeting to Friday?">
  Hey, can we push the meeting to Friday?
</p>