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
- Paste the snippet just before
</head>. - Add
data-rewrite-diffto a paragraph, and list the versions to rewrite through indata-versions, separated by|. - Words both versions share stay where they are; only the differences animate.
Options
Add any of these attributes to the same element.
data-versions | The versions, separated by |. The element’s own text is used as the first if it isn’t in the list. |
data-interval | How long each version shows. Default 2400 ms. |
data-duration | How long each rewrite takes. Default 1100 ms. |
data-loop | Go back to the first version and start again. |
data-label | Show 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.
<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><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>