← Typing

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

  1. Paste the snippet just before </head>.
  2. Add data-collaborative-cursors to 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-titleA document name for a header bar, with an avatar for each person. Without it there is no header.
data-colorOn an <ins>: that person’s colour. A palette is used otherwise.
data-delayOn an <ins>: how long that person waits before starting.
data-speedAverage typing time per character. Default 85 ms; each person varies around it.
data-loopErase what they typed and type it again after a pause.
data-holdHow 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.

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