Collaborative Cursors
A shared document comes alive as several people edit it at once. Each has a coloured caret with a name flag, types in their own part of the page at their own pace, and pauses now and then to think, just like a live multiplayer editor.
Friday: we ship version 2 in the morning. The docs are nearly done, just screenshots left.
Marketing: a 30 second teaser and a blog post
Open questions: do we need a status page?
How to use it
- Paste the snippet just before
</head>. - Add
data-collaborative-cursorsto a container of paragraphs, and wrap the text each person types in<ins data-user="Name">.
Options
Add any of these attributes to the same element.
data-title | A document name for a header bar, with an avatar for each person. Without it there is no header. |
data-color | On an <ins>: that person’s colour. A palette is used otherwise. |
data-delay | On an <ins>: how long that person waits before starting. |
data-speed | Average typing time per character. Default 85 ms; each person varies around it. |
data-loop | Erase what they typed and type it again after a pause. |
data-hold | How long the finished text stays before looping. Default 2400 ms. |
Name flags flip to the other side of the caret near the right-hand edge so they never get cut off. The document keeps its finished height while they type, so nothing below it moves.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Collaborative Cursors: a shared document where several named cursors type
// in different places at the same time, each with its own colour, name flag
// and rhythm, like a live multiplayer editor.
// Usage:
// <div data-collaborative-cursors data-title="Launch plan">
// <p>We ship on Friday.<ins data-user="Mia"> Docs are nearly done.</ins></p>
// <p>Video: <ins data-user="Leo" data-color="#0ea5e9">a 30 second teaser</ins></p>
// </div>
// Each <ins data-user> is text that person types; the rest is already there.
// Optional: data-title (document name in the header; without it there is no
// header), data-speed (average ms per character, default 85), data-loop
// (erase and type again), data-hold (ms the finished text stays, default
// 2400). On an <ins>: data-color (cursor colour; a palette is used
// otherwise), data-delay (ms before that person starts).
var DEFAULTS = { speed: 85, hold: 2400 };
var COLOURS = ['#e11d48', '#0ea5e9', '#16a34a', '#f59e0b', '#8b5cf6', '#db2777'];
var css =
'.ccu{position:relative;text-align:left;background:#fff;color:#1f2328;border-radius:14px;border:1px solid rgba(0,0,0,.08);' +
'box-shadow:0 20px 44px -16px rgba(40,10,70,.3);overflow:hidden}' +
'.ccu-top{display:flex;align-items:center;gap:0.6em;padding:0.6em 1em;border-bottom:1px solid #eceef1;font-weight:600;font-size:0.9em}' +
'.ccu-top>span:first-child{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'.ccu-ppl{display:flex;flex:none}' +
'.ccu-ppl b{display:grid;place-items:center;width:1.75em;height:1.75em;margin-left:-0.35em;border-radius:50%;border:2px solid #fff;color:#fff;font-size:0.8em}' +
'.ccu-doc{padding:1.3em 1.2em 0.9em;line-height:1.6}' +
'.ccu-doc>*{margin:0 0 0.7em}' +
'.ccu-doc h1,.ccu-doc h2,.ccu-doc h3{font-size:1.25em;line-height:1.3}' +
'.ccu-ins{text-decoration:none}' +
'.ccu-caret{position:relative;display:inline-block;width:2px;height:1.25em;margin:0 -1px;vertical-align:-0.25em;background:var(--ccu-c)}' +
'.ccu-flag{position:absolute;left:0;bottom:100%;padding:0.1em 0.45em;border-radius:0.35em 0.35em 0.35em 0;background:var(--ccu-c);color:#fff;' +
'font-size:0.68em;font-weight:600;line-height:1.4;white-space:nowrap;pointer-events:none}' +
'.ccu-caret.ccu-flip .ccu-flag{left:auto;right:0;border-radius:0.35em 0.35em 0 0.35em}' +
'.ccu-caret.ccu-idle{animation:ccu-blink 1.06s steps(1) infinite}@keyframes ccu-blink{50%{opacity:.15}}';
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 opt(d, k) { return d[k] !== undefined && d[k] !== '' ? d[k] : DEFAULTS[k]; }
function setup(el) {
var d = el.dataset;
el.classList.add('ccu');
var doc = document.createElement('div');
doc.className = 'ccu-doc';
while (el.firstChild) doc.appendChild(el.firstChild);
var people = [];
var cursors = Array.from(doc.querySelectorAll('ins[data-user]')).map(function (ins, i) {
var name = ins.getAttribute('data-user');
var colour = ins.getAttribute('data-color') || COLOURS[i % COLOURS.length];
if (!people.some(function (p) { return p.name === name; })) people.push({ name: name, colour: colour });
var span = document.createElement('span');
span.className = 'ccu-ins';
var text = ins.textContent;
var typed = document.createTextNode(text);
span.appendChild(typed);
ins.parentNode.replaceChild(span, ins);
var caret = document.createElement('span');
caret.className = 'ccu-caret';
caret.setAttribute('aria-hidden', 'true');
caret.style.setProperty('--ccu-c', colour);
caret.innerHTML = '<span class="ccu-flag"></span>';
caret.firstChild.textContent = name;
return { text: text, span: span, typed: typed, caret: caret, colour: colour, delay: +ins.getAttribute('data-delay') || i * 350 };
});
if (d.title) {
var top = document.createElement('div');
top.className = 'ccu-top';
top.setAttribute('aria-hidden', 'true');
top.innerHTML = '<span></span><span class="ccu-ppl"></span>';
top.firstChild.textContent = d.title;
people.forEach(function (p) {
var b = document.createElement('b');
b.style.background = p.colour;
b.textContent = p.name.charAt(0).toUpperCase();
b.title = p.name;
top.lastChild.appendChild(b);
});
el.appendChild(top);
}
el.appendChild(doc);
return { el: el, doc: doc, cursors: cursors, visible: false, wake: null };
}
function whenVisible(s) {
return s.visible ? Promise.resolve() : new Promise(function (r) { s.wake = r; });
}
// Flip the name flag to the caret's left when it nears the right-hand edge.
function place(s, c) {
var box = s.doc.getBoundingClientRect();
var r = c.caret.getBoundingClientRect();
c.caret.classList.toggle('ccu-flip', r.left - box.left > box.width * 0.7);
}
function show(s, c, n) {
c.typed.data = c.text.slice(0, n);
if (c.caret.parentNode !== c.span) c.span.appendChild(c.caret);
place(s, c);
}
// One person's typing: their own speed, little pauses between words.
function typeOne(s, c, speed) {
var pace = speed * (0.7 + Math.random() * 0.6);
return sleep(c.delay).then(function () {
return new Promise(function (done) {
var n = 0;
c.caret.classList.remove('ccu-idle');
(function tick() {
show(s, c, n);
if (n++ >= c.text.length) { c.caret.classList.add('ccu-idle'); done(); return; }
var ch = c.text.charAt(n - 1);
var wait = pace * (0.5 + Math.random());
if (ch === ' ' && Math.random() < 0.25) wait += pace * 6;
if (/[,.!?]/.test(ch)) wait += pace * 4;
setTimeout(tick, wait);
})();
});
});
}
function eraseOne(s, c) {
return new Promise(function (done) {
var n = c.text.length;
c.caret.classList.remove('ccu-idle');
(function tick() {
show(s, c, n);
if (n-- <= 0) { done(); return; }
setTimeout(tick, 14 + Math.random() * 10);
})();
});
}
// Hold the finished document's height so the card never grows while they type.
function reserve(s) {
s.doc.style.minHeight = '';
s.cursors.forEach(function (c) { c.typed.data = c.text; });
s.doc.style.minHeight = s.doc.getBoundingClientRect().height + 'px';
}
function play(s) {
var d = s.el.dataset;
var speed = +opt(d, 'speed');
reserve(s);
s.cursors.forEach(function (c) {
show(s, c, 0);
c.caret.classList.add('ccu-idle');
anime.animate(c.caret, { opacity: [0, 1], duration: 300, ease: 'out(2)' });
anime.animate(c.caret.firstChild, { scale: [0.6, 1], opacity: [0, 1], duration: 380, ease: 'outBack(2)' });
});
Promise.all(s.cursors.map(function (c) { return typeOne(s, c, speed); })).then(function () {
if (d.loop === undefined || d.loop === 'false') {
return sleep(1600).then(function () {
s.cursors.forEach(function (c) { anime.animate(c.caret, { opacity: 0, duration: 400, onComplete: function () { c.caret.remove(); } }); });
});
}
return sleep(+opt(d, 'hold')).then(function () { return whenVisible(s); }).then(function () {
return Promise.all(s.cursors.map(function (c, i) { return sleep(i * 150).then(function () { return eraseOne(s, c); }); }));
}).then(function () { return sleep(500); }).then(function () { play(s); });
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._ccu;
s.visible = e.isIntersecting;
if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-collaborative-cursors]').forEach(function (el) {
if (el._ccu) return;
var s = (el._ccu = setup(el));
if (reduced()) return;
s.cursors.forEach(function (c) { show(s, c, 0); });
io.observe(el);
whenVisible(s).then(function () { play(s); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-collaborative-cursors data-title="Launch plan" data-loop>
<p>We ship on Friday.<ins data-user="Mia"> Docs are nearly done.</ins></p>
<p>Video: <ins data-user="Leo">a 30 second teaser</ins></p>
</div>