Fan-out choreography
One event travels into a topic, splits into four deliveries that reach their subscribers on slightly different ticks, and each subscriber sends an acknowledgment back down the same wire with its own latency. Packets are a single dash riding a path normalised to pathLength="100", so position is nothing but stroke-dashoffset — compositor-cheap, no per-frame geometry; ingress uses ease-out because the arrival is the moment being read, while fan-out and ack use ease-in-out so each packet launches and lands. Every keyframe percentage is an absolute position in one 7.2s beat rather than a per-element delay, which is what lets the four subscribers fall out of step with each other and still reset together under the dissolve. Reach for it when explaining pub/sub, event buses, webhook delivery, or replication — anywhere the point is that one write becomes many, and that "many" is never simultaneous.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fan-out</title>
<style>
:root {
--ivory: #faf9f5;
--paper: #ffffff;
--slate: #141413;
--clay: #d97757;
--clay-d: #b85c3e;
--oat: #e3dacc;
--olive: #788c5d;
--rust: #b04a3f;
--gray-300: #d1cfc5;
--gray-500: #87867f;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* One cycle. Every keyframe percentage below is an absolute position in
this 7.2s beat, so the four subscribers can be out of step with each
other and still land back at rest together before the replay. */
--cycle: 7.2s;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
/* ─── KEEP FROM HERE ───────────────────────────────────────────────
.fanout (the 4:3 frame), the <svg> inside it, and every rule below
are the animation. The :root tokens, body centring, and the
reduced-motion block travel with it. Everything above is demo
chrome you can drop. */
/* Sized off both axes so the 4:3 frame never overflows a short, wide
stage: 126vh caps the height at 94.5vh. The 500px ceiling keeps the
type from ballooning on a large frame — the whole diagram is one
480-unit viewBox, so every label scales with this width. */
.fanout {
width: min(500px, 98vw, 126vh);
aspect-ratio: 4 / 3;
}
.fanout svg {
display: block;
width: 100%;
height: 100%;
}
text {
font-family:
ui-monospace,
'SF Mono',
Menlo,
Consolas,
monospace;
/* User units, not CSS px: rendered size is 18.5 x (frame width / 480),
i.e. 11.8px on the narrowest frame and 19.3px on the widest. Fluid
by construction — no breakpoint, no jump. */
font-size: 18.5px;
letter-spacing: 0.06em;
fill: var(--gray-500);
}
text.strong {
fill: var(--slate);
}
/* The whole scene breathes out and back in once per cycle. Blur on the
way out keeps the reset from reading as two overlapping diagrams. */
#scene {
animation: scene-cycle var(--cycle) linear infinite;
}
@keyframes scene-cycle {
0%,
84% {
opacity: 1;
filter: blur(0);
}
91%,
95% {
opacity: 0;
filter: blur(5px);
}
100% {
opacity: 1;
filter: blur(0);
}
}
.wire {
fill: none;
stroke: var(--gray-300);
stroke-width: 1.25;
}
/* A single 7-unit dash riding a path normalised to pathLength="100":
the packet's position is stroke-dashoffset, which is compositor-cheap
and needs no per-frame geometry. It parks fully clear of both ends
(offset 14 / -110) rather than flush against them — a round linecap
sitting exactly on the endpoint would leave a stray dot behind. */
.pulse {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 7 200;
stroke-dashoffset: -110;
}
.pulse.out {
stroke: var(--clay);
}
.pulse.back {
stroke: var(--olive);
}
@keyframes travel-out {
0% {
stroke-dashoffset: 14;
}
11.8%,
100% {
stroke-dashoffset: -110;
}
}
@keyframes travel-back {
0% {
stroke-dashoffset: -110;
}
11.8%,
100% {
stroke-dashoffset: 14;
}
}
/* Ingress: the event decelerating into the topic — ease-out, because the
arrival is the moment being read. */
#ingress {
animation: travel-out var(--cycle) var(--ease-out) 0.15s infinite both;
}
/* Fan-out and acknowledgement are travel across the frame, so ease-in-out:
each packet launches and lands. The delays are the whole point — no two
subscribers are served on the same tick. */
.fan {
animation: travel-out var(--cycle) var(--ease-in-out) infinite both;
}
#fan-1 {
animation-delay: 0.92s;
}
#fan-2 {
animation-delay: 1.05s;
}
#fan-3 {
animation-delay: 0.98s;
}
#fan-4 {
animation-delay: 1.18s;
}
.ack {
animation: travel-back var(--cycle) var(--ease-in-out) infinite both;
}
#ack-1 {
animation-delay: 2.45s;
}
#ack-2 {
animation-delay: 2.8s;
}
#ack-3 {
animation-delay: 2.62s;
}
#ack-4 {
animation-delay: 3.05s;
}
#ack-home {
animation: travel-back var(--cycle) var(--ease-out) 3.95s infinite both;
}
/* ── publisher ───────────────────────────────────────────────── */
#emit {
fill: none;
stroke: var(--clay);
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
animation: emit var(--cycle) var(--ease-out) infinite;
}
@keyframes emit {
0% {
opacity: 0.55;
transform: scale(1);
}
9%,
100% {
opacity: 0;
transform: scale(2.3);
}
}
#publisher {
fill: var(--paper);
stroke: var(--clay);
stroke-width: 1.75;
animation: publisher-settle var(--cycle) linear infinite;
}
@keyframes publisher-settle {
0%,
64% {
stroke: var(--clay);
}
66%,
93% {
stroke: var(--olive);
}
94%,
100% {
stroke: var(--clay);
}
}
/* ── topic ───────────────────────────────────────────────────── */
#topic-flash {
fill: var(--clay);
transform-box: fill-box;
transform-origin: center;
animation: topic-flash var(--cycle) var(--ease-out) infinite;
}
@keyframes topic-flash {
0%,
8% {
opacity: 0;
transform: scaleX(1);
}
10.5% {
opacity: 1;
transform: scaleX(1.5);
}
18%,
100% {
opacity: 0;
transform: scaleX(1);
}
}
/* ── subscribers ─────────────────────────────────────────────── */
.sub {
fill: var(--oat);
stroke: var(--gray-300);
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
}
/* Four near-identical curves rather than one shared curve plus delays:
a delay would drag each subscriber's reset past the dissolve and into
the next event. Absolute percentages keep the reset collective. */
#sub-1 {
animation: deliver-1 var(--cycle) var(--ease-out) infinite;
}
#sub-2 {
animation: deliver-2 var(--cycle) var(--ease-out) infinite;
}
#sub-3 {
animation: deliver-3 var(--cycle) var(--ease-out) infinite;
}
#sub-4 {
animation: deliver-4 var(--cycle) var(--ease-out) infinite;
}
@keyframes deliver-1 {
0%,
24.5% {
fill: var(--oat);
stroke: var(--gray-300);
transform: scale(1);
}
26% {
fill: var(--clay);
stroke: var(--clay);
transform: scale(1.2);
}
30%,
93% {
fill: var(--olive);
stroke: var(--olive);
transform: scale(1);
}
94%,
100% {
fill: var(--oat);
stroke: var(--gray-300);
transform: scale(1);
}
}
@keyframes deliver-2 {
0%,
26.3% {
fill: var(--oat);
stroke: var(--gray-300);
transform: scale(1);
}
27.8% {
fill: var(--clay);
stroke: var(--clay);
transform: scale(1.2);
}
31.8%,
93% {
fill: var(--olive);
stroke: var(--olive);
transform: scale(1);
}
94%,
100% {
fill: var(--oat);
stroke: var(--gray-300);
transform: scale(1);
}
}
@keyframes deliver-3 {
0%,
25.3% {
fill: var(--oat);
stroke: var(--gray-300);
transform: scale(1);
}
26.8% {
fill: var(--clay);
stroke: var(--clay);
transform: scale(1.2);
}
30.8%,
93% {
fill: var(--olive);
stroke: var(--olive);
transform: scale(1);
}
94%,
100% {
fill: var(--oat);
stroke: var(--gray-300);
transform: scale(1);
}
}
@keyframes deliver-4 {
0%,
28.1% {
fill: var(--oat);
stroke: var(--gray-300);
transform: scale(1);
}
29.6% {
fill: var(--clay);
stroke: var(--clay);
transform: scale(1.2);
}
33.6%,
93% {
fill: var(--olive);
stroke: var(--olive);
transform: scale(1);
}
94%,
100% {
fill: var(--oat);
stroke: var(--gray-300);
transform: scale(1);
}
}
/* Per-subscriber latency, revealed at the instant that subscriber acks. */
.lat {
font-size: 17.5px;
fill: var(--gray-500);
opacity: 0;
}
#lat-1 {
animation: lat-1 var(--cycle) var(--ease-out) infinite;
}
#lat-2 {
animation: lat-2 var(--cycle) var(--ease-out) infinite;
}
#lat-3 {
animation: lat-3 var(--cycle) var(--ease-out) infinite;
}
#lat-4 {
animation: lat-4 var(--cycle) var(--ease-out) infinite;
}
@keyframes lat-1 {
0%,
34% {
opacity: 0;
transform: translateY(3px);
}
38%,
93% {
opacity: 1;
transform: translateY(0);
}
94%,
100% {
opacity: 0;
transform: translateY(3px);
}
}
@keyframes lat-2 {
0%,
38.9% {
opacity: 0;
transform: translateY(3px);
}
42.9%,
93% {
opacity: 1;
transform: translateY(0);
}
94%,
100% {
opacity: 0;
transform: translateY(3px);
}
}
@keyframes lat-3 {
0%,
36.4% {
opacity: 0;
transform: translateY(3px);
}
40.4%,
93% {
opacity: 1;
transform: translateY(0);
}
94%,
100% {
opacity: 0;
transform: translateY(3px);
}
}
@keyframes lat-4 {
0%,
42.4% {
opacity: 0;
transform: translateY(3px);
}
46.4%,
93% {
opacity: 1;
transform: translateY(0);
}
94%,
100% {
opacity: 0;
transform: translateY(3px);
}
}
/* The settled state: the beat the loop holds before dissolving. */
#tally {
opacity: 0;
fill: var(--olive);
animation: tally var(--cycle) var(--ease-out) infinite;
}
@keyframes tally {
0%,
66% {
opacity: 0;
transform: translateY(3px);
}
70%,
93% {
opacity: 1;
transform: translateY(0);
}
94%,
100% {
opacity: 0;
transform: translateY(3px);
}
}
/* Reduced motion: the packets stop travelling and the scene never
dissolves — the diagram stays permanently settled, every subscriber
delivered and acknowledged. But settled is not stopped: the topic
glows softly, then each subscriber and its latency label brighten in
the same staggered order the full version delivers them (41ms, 58ms,
76ms, 94ms), and the tally brightens last. Same claim — one event,
four subscribers, no two on the same tick — told as a slow 9s breath
in opacity alone, at 1.25x the full cycle, with no reset to hide: the
curves start and end on the same dim value. The travel animations are
still cancelled by id, not by class: an id rule set them, so a class
rule here would lose the cascade. */
@media (prefers-reduced-motion: reduce) {
#scene,
#ingress,
.fan,
.ack,
#ack-home,
#emit,
#publisher {
animation: none;
}
#emit,
.pulse {
opacity: 0;
}
#publisher {
stroke: var(--olive);
}
.sub {
fill: var(--olive);
stroke: var(--olive);
}
.lat,
#tally {
transform: none;
}
/* The topic never stops receiving: a slow clay wash at the head of
each breath, well short of the flash the full version fires. */
@keyframes topic-glow-reduced {
0%,
22%,
100% {
opacity: 0;
}
8% {
opacity: 0.38;
}
}
#topic-flash {
opacity: 0;
transform: none;
animation: topic-glow-reduced 9s var(--ease-in-out) infinite;
}
@keyframes settled-breathe-reduced {
0%,
100% {
opacity: 0.45;
}
45% {
opacity: 1;
}
}
@keyframes label-breathe-reduced {
0%,
100% {
opacity: 0.4;
}
45% {
opacity: 1;
}
}
#sub-1,
#sub-2,
#sub-3,
#sub-4 {
animation: settled-breathe-reduced 9s var(--ease-in-out) infinite both;
}
#lat-1,
#lat-2,
#lat-3,
#lat-4 {
animation: label-breathe-reduced 9s var(--ease-in-out) infinite both;
}
/* The stagger, kept: fastest ack leads, slowest trails. */
#sub-1,
#lat-1 {
animation-delay: 0.55s;
}
#sub-3,
#lat-3 {
animation-delay: 0.9s;
}
#sub-2,
#lat-2 {
animation-delay: 1.25s;
}
#sub-4,
#lat-4 {
animation-delay: 1.65s;
}
/* The count lands after the four it counts. */
#tally {
animation: label-breathe-reduced 9s var(--ease-in-out) 2.2s infinite both;
}
}
</style>
</head>
<body>
<div class="fanout">
<svg viewBox="0 0 480 360" xmlns="http://www.w3.org/2000/svg" role="img"
aria-label="One event published to a topic, fanned out to four subscribers with staggered delivery, each acknowledging back">
<g id="scene">
<!-- publisher -->
<circle id="emit" cx="66" cy="180" r="17" />
<circle id="publisher" cx="66" cy="180" r="17" />
<circle cx="66" cy="180" r="4.5" fill="var(--clay)" />
<text x="66" y="216" text-anchor="middle">publisher</text>
<!-- ingress -->
<path class="wire" d="M 86 180 L 197 180" />
<path id="ingress" class="pulse out" d="M 86 180 L 197 180" pathLength="100" />
<path id="ack-home" class="pulse back" d="M 86 180 L 197 180" pathLength="100" />
<!-- topic -->
<text x="211" y="60" text-anchor="middle" class="strong">order.created</text>
<rect x="198" y="72" width="26" height="216" rx="13" fill="var(--slate)" />
<rect id="topic-flash" x="198" y="72" width="26" height="216" rx="13" />
<text x="211" y="306" text-anchor="middle">topic</text>
<!-- fan-out wires -->
<path class="wire" d="M 224 180 C 276 180, 292 84, 344 84" />
<path class="wire" d="M 224 180 C 276 180, 292 148, 344 148" />
<path class="wire" d="M 224 180 C 276 180, 292 212, 344 212" />
<path class="wire" d="M 224 180 C 276 180, 292 276, 344 276" />
<path id="fan-1" class="pulse fan out" d="M 224 180 C 276 180, 292 84, 344 84" pathLength="100" />
<path id="fan-2" class="pulse fan out" d="M 224 180 C 276 180, 292 148, 344 148" pathLength="100" />
<path id="fan-3" class="pulse fan out" d="M 224 180 C 276 180, 292 212, 344 212" pathLength="100" />
<path id="fan-4" class="pulse fan out" d="M 224 180 C 276 180, 292 276, 344 276" pathLength="100" />
<path id="ack-1" class="pulse ack back" d="M 224 180 C 276 180, 292 84, 344 84" pathLength="100" />
<path id="ack-2" class="pulse ack back" d="M 224 180 C 276 180, 292 148, 344 148" pathLength="100" />
<path id="ack-3" class="pulse ack back" d="M 224 180 C 276 180, 292 212, 344 212" pathLength="100" />
<path id="ack-4" class="pulse ack back" d="M 224 180 C 276 180, 292 276, 344 276" pathLength="100" />
<!-- subscribers -->
<circle id="sub-1" class="sub" cx="358" cy="84" r="13" />
<circle id="sub-2" class="sub" cx="358" cy="148" r="13" />
<circle id="sub-3" class="sub" cx="358" cy="212" r="13" />
<circle id="sub-4" class="sub" cx="358" cy="276" r="13" />
<!-- Name sits 5 units above its node's centre line, latency 14 below:
19 units apart, enough that the name's descenders clear the tops
of "ack NNms" at this type size, with the pair still balanced on
the node it labels. -->
<text x="378" y="79" class="strong">cache</text>
<text x="378" y="143" class="strong">search</text>
<text x="378" y="207" class="strong">email</text>
<text x="378" y="271" class="strong">audit</text>
<text id="lat-1" class="lat" x="378" y="98">ack 41ms</text>
<text id="lat-2" class="lat" x="378" y="162">ack 76ms</text>
<text id="lat-3" class="lat" x="378" y="226">ack 58ms</text>
<text id="lat-4" class="lat" x="378" y="290">ack 94ms</text>
<!-- settled tally -->
<text x="24" y="340">1 event</text>
<text id="tally" x="456" y="340" text-anchor="end">4 delivered · 4 acked</text>
</g>
</svg>
</div>
</body>
</html>