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
- Paste the snippet just before
</head>. - Add
data-sequence-diagramto a container with one child per message, in order. - Give each message
data-fromanddata-towith the participants’ names, and adddata-replyto replies.
Options
Add any of these attributes to the same element.
data-from / data-to | On a message: who sends it and who receives it. The same name in both draws a loop. |
data-reply | On a message: draw it dashed with an open arrowhead. |
data-actors | Comma-separated participants, left to right. Default: in the order they first appear. |
data-color | Colour of the arrows and participants. Default #a855f7. |
data-step | Time between messages. Default 750 ms. |
data-loop | Clear 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.
<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><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>