← Diagrams & maps

Sequence Diagram

Who talks to whom, and in what order. The participants line up along the top and their dashed lifelines drop down, then each message arrows across from one lifeline to the next with its label sliding in above it. Replies come back dashed, and each participant blinks as a message reaches it.

Tap Buy POST /order Check stock Save order Saved 201 Created Receipt

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sequence-diagram to a container with one child per message, in order.
  3. Give each message data-from and data-to with the participants’ names, and add data-reply to replies.

Options

Add any of these attributes to the same element.

data-from / data-toOn a message: who sends it and who receives it. The same name in both draws a loop.
data-replyOn a message: draw it dashed with an open arrowhead.
data-actorsComma-separated participants, left to right. Default: in the order they first appear.
data-colorColour of the arrows and participants. Default #a855f7.
data-stepTime between messages. Default 750 ms.
data-loopClear the messages and play them again.

It fills the width of its container and grows taller with each message. Text size follows its font-size; short labels fit best on phones.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sequence Diagram: participants line up along the top, their dashed
    // lifelines drop down, and then each message arrows across from one
    // lifeline to another in order, its label sliding in above it. Replies are
    // dashed with an open arrowhead, and a lifeline blinks as a message lands.
    // Usage:
    //   <div data-sequence-diagram data-actors="User,App,Server">
    //     <span data-from="User" data-to="App">Tap Buy</span>
    //     <span data-from="App" data-to="Server">POST /order</span>
    //     <span data-from="Server" data-to="App" data-reply>201 Created</span>
    //   </div>
    // One child per message, in order. A message from a participant to itself
    // loops back to its own lifeline.
    // Optional: data-actors (comma-separated, left to right; default: in the
    // order they first appear), data-reply on a message, data-color (default
    // #a855f7), data-step (ms per message, default 750), data-loop (clear and
    // play again).
    var DEFAULTS = { color: '#a855f7', step: 750 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.sqd{position:relative;width:100%;--sqd-c:#a855f7}' +
      '.sqd-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
      '.sqd-actor{position:absolute;top:0;translate:-50% 0;box-sizing:border-box;padding:.45em .55em;border-radius:.6em;background:#fff;border:2px solid var(--sqd-c);font-weight:700;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;box-shadow:0 3px 10px rgba(70,30,130,.1)}' +
      '.sqd-actor.sqd-hit{background:var(--sqd-c);color:#fff}' +
      '.sqd-label{position:absolute;font-size:.86em;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;color:#3b2a55}' +
      '.sqd-label.sqd-reply{color:#6b5a88;font-weight:500}' +
      '.sqd-head{transform-box:fill-box;transform-origin:center}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function mk(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }

    function setup(el) {
      var d = el.dataset, color = d.color || DEFAULTS.color;
      var s = (el._sqd = { actors: [], msgs: [], visible: false, running: false });
      el.classList.add('sqd');
      el.style.setProperty('--sqd-c', color);
      var names = d.actors ? d.actors.split(',').map(function (x) { return x.trim(); }).filter(Boolean) : [];
      var kids = Array.from(el.children);
      kids.forEach(function (k) {
        [k.getAttribute('data-from'), k.getAttribute('data-to')].forEach(function (a) {
          if (a && names.indexOf(a.trim()) < 0) names.push(a.trim());
        });
      });
      var svg = mk('svg', { class: 'sqd-svg', 'aria-hidden': 'true' });
      s.lifeLayer = mk('g', { stroke: '#c9bce3', 'stroke-width': 1.5, 'stroke-dasharray': '4 4' }, svg);
      s.msgLayer = mk('g', { fill: 'none', stroke: color, 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, svg);
      el.insertBefore(svg, el.firstChild);
      names.forEach(function (name) {
        var box = document.createElement('div');
        box.className = 'sqd-actor';
        box.textContent = name;
        box.setAttribute('aria-hidden', 'true');
        el.appendChild(box);
        s.actors.push({ name: name, el: box, line: mk('line', {}, s.lifeLayer) });
      });
      kids.forEach(function (k, i) {
        var from = names.indexOf((k.getAttribute('data-from') || '').trim());
        var to = names.indexOf((k.getAttribute('data-to') || '').trim());
        var reply = k.hasAttribute('data-reply');
        var label = document.createElement('div');
        label.className = 'sqd-label' + (reply ? ' sqd-reply' : '');
        label.setAttribute('aria-hidden', 'true');
        label.textContent = k.textContent.trim();
        el.replaceChild(label, k);
        s.msgs.push({
          from: Math.max(0, from), to: Math.max(0, to), reply: reply, text: label.textContent, label: label,
          line: mk('path', reply ? { 'stroke-dasharray': '5 4' } : {}, s.msgLayer),
          head: mk('path', { class: 'sqd-head', fill: reply ? 'none' : color }, s.msgLayer),
          ripple: mk('circle', { r: 4, fill: color, stroke: 'none', opacity: 0 }, s.msgLayer),
        });
      });
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) {
        el.setAttribute('aria-label', 'Sequence diagram: ' + s.msgs.map(function (m, i) {
          return (i + 1) + '. ' + names[m.from] + ' to ' + names[m.to] + ': ' + m.text;
        }).join('; '));
      }
      layout(el);
      new ResizeObserver(function () { layout(el); }).observe(el);
    }

    function layout(el) {
      var s = el._sqd, na = s.actors.length;
      var W = el.clientWidth;
      if (!W || !na) return;
      var col = W / na;
      var fs = parseFloat(getComputedStyle(el).fontSize) || 14;
      var headH = 0;
      s.actors.forEach(function (a, i) {
        a.x = col * (i + 0.5);
        a.el.style.left = a.x + 'px';
        a.el.style.maxWidth = Math.max(40, col - 8) + 'px';
        headH = Math.max(headH, a.el.offsetHeight);
      });
      var rowH = fs * 2.5, top = headH + fs * 1.3;
      s.msgs.forEach(function (m, i) {
        var y = top + i * rowH + fs * 1.25;
        var x1 = s.actors[m.from].x, x2 = s.actors[m.to].x, h = 6;
        m.y = y;
        if (m.from === m.to) {
          // A loop back to the same lifeline, on whichever side has room.
          var side = m.from === na - 1 && na > 1 ? -1 : 1, lw = Math.min(col * 0.35, 28);
          m.geo = { self: true, x: x1, y: y - 6, side: side, lw: lw };
          m.head.setAttribute('d', 'M' + (x1 + side * h) + ',' + (y + 6 - h * 0.6) + ' L' + x1 + ',' + (y + 6) + ' L' + (x1 + side * h) + ',' + (y + 6 + h * 0.6) + (m.reply ? '' : ' Z'));
          m.end = [x1, y + 6];
          m.label.style.left = x1 + side * (lw + 6) + 'px';
          m.label.style.translate = side > 0 ? '0 0' : '-100% 0';
          m.label.style.top = y - fs * 0.6 + 'px';
          m.label.style.maxWidth = Math.max(60, col - lw - 8) + 'px';
        } else {
          var dir = x2 > x1 ? 1 : -1;
          m.geo = { x: x1, y: y, x2: x2 - dir * 1.5 };
          m.head.setAttribute('d', 'M' + (x2 - dir * h * 1.4) + ',' + (y - h * 0.75) + ' L' + x2 + ',' + y + ' L' + (x2 - dir * h * 1.4) + ',' + (y + h * 0.75) + (m.reply ? '' : ' Z'));
          m.end = [x2, y];
          m.label.style.left = (x1 + x2) / 2 + 'px';
          m.label.style.translate = '-50% 0';
          m.label.style.top = y - fs * 1.35 + 'px';
          m.label.style.maxWidth = Math.max(30, Math.abs(x2 - x1) - 8) + 'px';
        }
        grow(m, m.t == null ? 1 : m.t);
        m.ripple.setAttribute('cx', m.end[0]);
        m.ripple.setAttribute('cy', m.end[1]);
      });
      var H = top + s.msgs.length * rowH + fs * 0.6;
      s.actors.forEach(function (a) {
        a.line.setAttribute('x1', a.x); a.line.setAttribute('x2', a.x);
        a.y1 = headH; a.y2 = H;
        a.line.setAttribute('y1', headH);
        a.line.setAttribute('y2', a.t == null ? H : headH + (H - headH) * a.t);
      });
      el.style.height = H + 'px';
    }

    // Draw a message's line part of the way (keeps reply dashes intact).
    function grow(m, t) {
      var g = m.geo;
      m.t = t;
      if (!g) return;
      if (t <= 0) { m.line.setAttribute('d', ''); return; }
      if (!g.self) {
        m.line.setAttribute('d', 'M' + g.x + ',' + g.y + ' L' + (g.x + (g.x2 - g.x) * t) + ',' + g.y);
        return;
      }
      var a = g.lw, b = 12, c = g.lw - 2, len = (a + b + c) * t;
      var d = 'M' + g.x + ',' + g.y + ' h' + g.side * Math.min(len, a);
      if (len > a) d += ' v' + Math.min(len - a, b);
      if (len > a + b) d += ' h' + -g.side * (len - a - b);
      m.line.setAttribute('d', d);
    }

    function play(el) {
      var s = el._sqd, d = el.dataset, step = +d.step || DEFAULTS.step;
      s.running = true;
      s.msgs.forEach(function (m, i) {
        var t = i * step;
        setTimeout(function () {
          anime.animate(m, { t: [0, 1], duration: step * 0.6, ease: 'inOut(2)', onUpdate: function () { grow(m, m.t); } });
          anime.animate(m.label, { opacity: [0, 1], y: [6, 0], duration: step * 0.6, delay: step * 0.15, ease: 'out(2)' });
          anime.animate(m.head, { opacity: [0, 1], scale: [0.3, 1], duration: 220, delay: step * 0.55, ease: 'outBack(2)' });
          anime.animate(m.ripple, { opacity: [0.6, 0], r: [3, 14], duration: 600, delay: step * 0.55, ease: 'out(2)' });
          var target = s.actors[m.to].el;
          setTimeout(function () {
            target.classList.add('sqd-hit');
            setTimeout(function () { target.classList.remove('sqd-hit'); }, step * 0.5);
          }, step * 0.55);
        }, t);
      });
      var end = s.msgs.length * step + 2200;
      setTimeout(function () {
        if (d.loop == null) { s.running = false; return; }
        anime.animate(s.msgs.map(function (m) { return m.label; }).concat(s.msgs.map(function (m) { return m.head; })), { opacity: 0, duration: 500 });
        s.msgs.forEach(function (m) { anime.animate(m.line, { opacity: [1, 0], duration: 500 }); });
        setTimeout(function () {
          s.msgs.forEach(function (m) {
            anime.utils.set(m.line, { opacity: 1 });
            grow(m, 0);
          });
          s.running = false;
          if (s.visible) play(el);
        }, 700);
      }, end);
    }

    function intro(el) {
      var s = el._sqd;
      anime.animate(s.actors.map(function (a) { return a.el; }), { opacity: [0, 1], y: [-10, 0], duration: 450, delay: anime.stagger(90), ease: 'out(3)' });
      s.actors.forEach(function (a, i) {
        anime.animate(a, { t: [0, 1], duration: 700, delay: 200 + i * 90, ease: 'out(2)', onUpdate: function () { a.line.setAttribute('y2', a.y1 + (a.y2 - a.y1) * a.t); } });
      });
      setTimeout(function () { play(el); }, 700);
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._sqd;
          s.visible = e.isIntersecting;
          if (!s.visible) return;
          if (!s.introduced) { s.introduced = true; intro(el); return; }
          if (!s.running) play(el);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-sequence-diagram]').forEach(function (el) {
        if (el._sqd) return;
        setup(el);
        if (still) return;
        var s = el._sqd;
        s.actors.forEach(function (a) {
          anime.utils.set(a.el, { opacity: 0 });
          a.t = 0;
          a.line.setAttribute('y2', a.y1);
        });
        s.msgs.forEach(function (m) {
          anime.utils.set([m.label, m.head], { opacity: 0 });
          grow(m, 0);
        });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sequence-diagram data-actors="User,App,Server">
  <span data-from="User" data-to="App">Tap Buy</span>
  <span data-from="App" data-to="Server">POST /order</span>
  <span data-from="Server" data-to="App" data-reply>201 Created</span>
  <span data-from="App" data-to="User" data-reply>Receipt</span>
</div>