Dwell & Transition
A finite state machine executes itself: a single token occupies IDLE, dwells, then travels labeled edges through ARMED and RUNNING, faults into RETRY, loops back, and finally commits to SETTLED. Every traversal is ease-in-out — the token has mass, accelerating off one state and decelerating into the next — while arrivals use a short ease-out overshoot so landing reads as a settle rather than a stop; edges draw themselves under the token and stay as a faint trace, so the walk accumulates into a visible history. The dwell (~800ms) is deliberately longer than the move (620ms), which is what makes the rhythm feel intentional instead of restless. Reach for it when you need to explain a workflow, retry policy, or lifecycle — anywhere a static box-and-arrow diagram fails to convey that only one thing is true at a time.
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>Dwell & Transition — a state machine walking its own graph</title>
<style>
/* ============================================================
DWELL & TRANSITION
A finite state machine executing. A token occupies a state,
dwells there, then travels an edge to the next. Edges light
as they are taken and stay as a faint trace of the walk.
WHEN PASTING INTO A REAL PROJECT:
keep → .stage and everything inside it (the <svg> + the
readout), plus every rule from "SCENE" down.
drop → the page-level `body`/`.card`/`.card-head` chrome
and the `--T` loop wrapper if you would rather
drive state changes from your own app instead of
a 12-second cycle. Every keyframe is a percentage
of --T, so retiming the whole piece is one value.
============================================================ */
:root {
--ivory: #FAF9F5;
--ink: #141413;
--clay: #D97757;
--oat: #E3DACC;
--olive: #788C5D;
--rust: #B04A3F;
--gray-500: #87867F;
--gray-300: #D1CFC5;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--serif: ui-serif, Georgia, "Times New Roman", serif;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* one knob for the whole loop */
--T: 12s;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--ivory);
color: var(--ink);
font-family: var(--mono);
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 20px;
-webkit-font-smoothing: antialiased;
}
/* ── card: 4:3 ─────────────────────────────────── */
.card {
width: min(660px, 100%);
aspect-ratio: 4 / 3;
background: var(--ivory);
border: 1px solid var(--oat);
border-radius: 14px;
padding: 20px 22px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--oat);
}
.eyebrow {
font-size: 10.5px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gray-500);
}
.eyebrow strong { color: var(--ink); font-weight: 500; }
/* ── SCENE ─────────────────────────────────────── */
.stage {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
animation: scene-cycle var(--T) linear infinite;
will-change: opacity, filter;
}
.stage svg { flex: 1; min-height: 0; width: 100%; display: block; overflow: visible; }
/* the loop: fade up, run, hold the settled state, dissolve, replay.
blur bridges the dissolve so the two cycles read as one object. */
@keyframes scene-cycle {
0% { opacity: 0; filter: blur(4px); }
4% { opacity: 1; filter: blur(0); }
91.67% { opacity: 1; filter: blur(0); }
97.5% { opacity: 0; filter: blur(4px); }
100% { opacity: 0; filter: blur(4px); }
}
/* ── graph chrome ──────────────────────────────── */
.edge-base { stroke: var(--gray-300); stroke-width: 1.5; fill: none; }
.head-base { fill: var(--gray-300); }
.edge-lit {
stroke: var(--accent, var(--clay));
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
opacity: 0;
}
.head-lit { fill: var(--accent, var(--clay)); opacity: 0; }
.edge-label {
font-family: var(--mono);
font-size: 9.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
fill: var(--gray-500);
opacity: 0;
}
.ring { fill: var(--ivory); stroke: var(--gray-300); stroke-width: 1.5; }
.n-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.13em;
text-transform: uppercase;
fill: var(--gray-500);
text-anchor: middle;
}
.lit { opacity: 0; }
.lit .glow { fill: var(--accent, var(--clay)); opacity: 0.11; }
.lit .halo { fill: none; stroke: var(--accent, var(--clay)); stroke-width: 1.75; }
.lit .n-label { fill: var(--accent, var(--clay)); }
.n-d, .e3, .e4 { --accent: var(--rust); }
.n-e, .e5 { --accent: var(--olive); }
/* ── the token ─────────────────────────────────── */
.token { transform-box: fill-box; transform-origin: center; }
.token-halo { fill: var(--clay); opacity: 0.13; }
.token-core { fill: var(--clay); }
/* dwell, then move. the move is ease-in-out — the token has mass;
it accelerates off the state and decelerates into the next one.
the arrival overshoot is ease-out, so landing reads as a settle. */
@keyframes travel {
0% { transform: translate(58px,165px) scale(0.86); animation-timing-function: var(--ease-out); }
4% { transform: translate(58px,165px) scale(1); }
12.5% { transform: translate(58px,165px) scale(1); animation-timing-function: var(--ease-in-out); }
17.67% { transform: translate(170px,84px) scale(1.24); animation-timing-function: var(--ease-out); }
19.2% { transform: translate(170px,84px) scale(1); }
24.17% { transform: translate(170px,84px) scale(1); animation-timing-function: var(--ease-in-out); }
29.33% { transform: translate(282px,165px) scale(1.24); animation-timing-function: var(--ease-out); }
30.9% { transform: translate(282px,165px) scale(1); }
35.83% { transform: translate(282px,165px) scale(1); animation-timing-function: var(--ease-in-out); }
41% { transform: translate(170px,246px) scale(1.24); animation-timing-function: var(--ease-out); }
42.5% { transform: translate(170px,246px) scale(1); }
47.5% { transform: translate(170px,246px) scale(1); animation-timing-function: var(--ease-in-out); }
52.67% { transform: translate(170px,84px) scale(1.24); animation-timing-function: var(--ease-out); }
54.2% { transform: translate(170px,84px) scale(1); }
59.17% { transform: translate(170px,84px) scale(1); animation-timing-function: var(--ease-in-out); }
64.33% { transform: translate(282px,165px) scale(1.24); animation-timing-function: var(--ease-out); }
65.9% { transform: translate(282px,165px) scale(1); }
70.83% { transform: translate(282px,165px) scale(1); animation-timing-function: var(--ease-in-out); }
76% { transform: translate(394px,165px) scale(1.3); animation-timing-function: var(--ease-out); }
77.8% { transform: translate(394px,165px) scale(1); }
100% { transform: translate(394px,165px) scale(1); }
}
.token { animation: travel var(--T) linear infinite; }
/* ── edges drawing themselves under the token ──── */
.e1 .edge-lit { animation: sweep-e1 var(--T) var(--ease-in-out) infinite; }
.e2 .edge-lit { animation: sweep-e2 var(--T) var(--ease-in-out) infinite; }
.e3 .edge-lit { animation: sweep-e3 var(--T) var(--ease-in-out) infinite; }
.e4 .edge-lit { animation: sweep-e4 var(--T) var(--ease-in-out) infinite; }
.e5 .edge-lit { animation: sweep-e5 var(--T) var(--ease-in-out) infinite; }
@keyframes sweep-e1 {
0%, 12.5% { stroke-dashoffset: 100; opacity: 0.9; }
17.67% { stroke-dashoffset: 0; opacity: 0.9; }
21% { opacity: 0.42; }
100% { stroke-dashoffset: 0; opacity: 0.42; }
}
@keyframes sweep-e2 {
0%, 24.17% { stroke-dashoffset: 100; opacity: 0.9; }
29.33% { stroke-dashoffset: 0; opacity: 0.9; }
33% { opacity: 0.42; }
59.17% { opacity: 0.42; }
61.5% { opacity: 0.9; }
64.33% { opacity: 0.9; }
67.5% { opacity: 0.42; }
100% { stroke-dashoffset: 0; opacity: 0.42; }
}
@keyframes sweep-e3 {
0%, 35.83% { stroke-dashoffset: 100; opacity: 0.9; }
41% { stroke-dashoffset: 0; opacity: 0.9; }
44% { opacity: 0.42; }
100% { stroke-dashoffset: 0; opacity: 0.42; }
}
@keyframes sweep-e4 {
0%, 47.5% { stroke-dashoffset: 100; opacity: 0.9; }
52.67% { stroke-dashoffset: 0; opacity: 0.9; }
55.5% { opacity: 0.42; }
100% { stroke-dashoffset: 0; opacity: 0.42; }
}
@keyframes sweep-e5 {
0%, 70.83% { stroke-dashoffset: 100; opacity: 0.95; }
76% { stroke-dashoffset: 0; opacity: 0.95; }
100% { stroke-dashoffset: 0; opacity: 0.95; }
}
/* arrowheads + edge names share the reveal — the transition
names itself only once the machine has actually taken it. */
.e1 .head-lit, .e1 .edge-label { animation: mark-e1 var(--T) var(--ease-out) infinite; }
.e2 .head-lit, .e2 .edge-label { animation: mark-e2 var(--T) var(--ease-out) infinite; }
.e3 .head-lit, .e3 .edge-label { animation: mark-e3 var(--T) var(--ease-out) infinite; }
.e4 .head-lit, .e4 .edge-label { animation: mark-e4 var(--T) var(--ease-out) infinite; }
.e5 .head-lit, .e5 .edge-label { animation: mark-e5 var(--T) var(--ease-out) infinite; }
@keyframes mark-e1 {
0%, 15% { opacity: 0; }
17.67% { opacity: 1; }
21% { opacity: 0.55; }
100% { opacity: 0.55; }
}
@keyframes mark-e2 {
0%, 27% { opacity: 0; }
29.33% { opacity: 1; }
33% { opacity: 0.55; }
59.17% { opacity: 0.55; }
61.5% { opacity: 1; }
64.33% { opacity: 1; }
67.5% { opacity: 0.55; }
100% { opacity: 0.55; }
}
@keyframes mark-e3 {
0%, 38.5% { opacity: 0; }
41% { opacity: 1; }
44% { opacity: 0.55; }
100% { opacity: 0.55; }
}
@keyframes mark-e4 {
0%, 50% { opacity: 0; }
52.67% { opacity: 1; }
55.5% { opacity: 0.55; }
100% { opacity: 0.55; }
}
@keyframes mark-e5 {
0%, 73.5% { opacity: 0; }
76% { opacity: 1; }
100% { opacity: 1; }
}
/* ── states lighting as they become active ─────── */
.n-a .lit, .r-a { animation: lit-a var(--T) var(--ease-out) infinite; }
.n-b .lit, .r-b { animation: lit-b var(--T) var(--ease-out) infinite; }
.n-c .lit, .r-c { animation: lit-c var(--T) var(--ease-out) infinite; }
.n-d .lit, .r-d { animation: lit-d var(--T) var(--ease-out) infinite; }
.n-e .lit, .r-e { animation: lit-e var(--T) var(--ease-out) infinite; }
@keyframes lit-a {
0%, 12.5% { opacity: 1; }
14.6% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lit-b {
0%, 16% { opacity: 0; }
17.67% { opacity: 1; }
24.17% { opacity: 1; }
26.3% { opacity: 0; }
51% { opacity: 0; }
52.67% { opacity: 1; }
59.17% { opacity: 1; }
61.3% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lit-c {
0%, 27.7% { opacity: 0; }
29.33% { opacity: 1; }
35.83% { opacity: 1; }
37.9% { opacity: 0; }
62.7% { opacity: 0; }
64.33% { opacity: 1; }
70.83% { opacity: 1; }
72.9% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lit-d {
0%, 39.4% { opacity: 0; }
41% { opacity: 1; }
47.5% { opacity: 1; }
49.6% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lit-e {
0%, 74.4% { opacity: 0; }
76% { opacity: 1; }
100% { opacity: 1; }
}
/* ── readout ───────────────────────────────────── */
.readout {
display: flex;
align-items: baseline;
gap: 12px;
padding-top: 10px;
border-top: 1px solid var(--oat);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.r-key { color: var(--gray-500); }
.r-val { position: relative; flex: 1; height: 1.2em; }
.r-val i {
position: absolute;
inset: 0;
font-style: normal;
opacity: 0;
color: var(--clay);
}
.r-val i.r-d { color: var(--rust); }
.r-val i.r-e { color: var(--olive); }
.r-note { color: var(--gray-300); font-size: 10px; letter-spacing: 0.1em; }
/* ── reduced motion: no travel, no dissolve. the machine is
shown already settled — the full walk traced, SETTLED lit,
the token resting on its terminal state. ─────────────── */
@media (prefers-reduced-motion: reduce) {
.stage, .token, .edge-lit, .head-lit, .edge-label, .lit, .r-val i {
animation: none !important;
}
.stage { opacity: 1; filter: none; }
.token { transform: translate(394px, 165px); }
.edge-lit { stroke-dashoffset: 0; opacity: 0.42; }
.e5 .edge-lit { opacity: 0.95; }
.head-lit, .edge-label { opacity: 0.55; }
.e5 .head-lit, .e5 .edge-label { opacity: 1; }
.lit { opacity: 0; }
.n-e .lit { opacity: 1; }
.r-val i.r-e { opacity: 1; }
}
</style>
</head>
<body>
<div class="card">
<div class="card-head">
<span class="eyebrow"><strong>Finite state machine</strong> — dwell, then transition</span>
<span class="eyebrow">1 token</span>
</div>
<!-- KEEP FROM HERE ────────────────────────────────────────── -->
<div class="stage">
<svg viewBox="0 0 440 330" xmlns="http://www.w3.org/2000/svg" role="img"
aria-label="A state machine: a token moves from Idle to Armed to Running, faults into Retry, retries back to Armed, runs again, and commits to Settled.">
<!-- edges -->
<g class="edges">
<g class="e1">
<line class="edge-base" x1="84.7" y1="145.7" x2="143.3" y2="103.3"/>
<path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(143.3,103.3) rotate(-35.9)"/>
<line class="edge-lit" pathLength="100" x1="84.7" y1="145.7" x2="143.3" y2="103.3"/>
<path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(143.3,103.3) rotate(-35.9)"/>
<text class="edge-label" x="106" y="113" text-anchor="middle">arm</text>
</g>
<g class="e2">
<line class="edge-base" x1="196.7" y1="103.3" x2="255.3" y2="145.7"/>
<path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(255.3,145.7) rotate(35.9)"/>
<line class="edge-lit" pathLength="100" x1="196.7" y1="103.3" x2="255.3" y2="145.7"/>
<path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(255.3,145.7) rotate(35.9)"/>
<text class="edge-label" x="236" y="113" text-anchor="middle">run</text>
</g>
<g class="e3">
<line class="edge-base" x1="255.3" y1="184.3" x2="196.7" y2="226.7"/>
<path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(196.7,226.7) rotate(144.1)"/>
<line class="edge-lit" pathLength="100" x1="255.3" y1="184.3" x2="196.7" y2="226.7"/>
<path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(196.7,226.7) rotate(144.1)"/>
<text class="edge-label" x="247" y="232" text-anchor="middle">fault</text>
</g>
<g class="e4">
<line class="edge-base" x1="170" y1="213" x2="170" y2="117"/>
<path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(170,117) rotate(-90)"/>
<line class="edge-lit" pathLength="100" x1="170" y1="213" x2="170" y2="117"/>
<path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(170,117) rotate(-90)"/>
<text class="edge-label" x="160" y="169" text-anchor="end">retry</text>
</g>
<g class="e5">
<line class="edge-base" x1="315" y1="165" x2="361" y2="165"/>
<path class="head-base" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(361,165)"/>
<line class="edge-lit" pathLength="100" x1="315" y1="165" x2="361" y2="165"/>
<path class="head-lit" d="M0,0 L-8,3.6 L-8,-3.6 Z" transform="translate(361,165)"/>
<text class="edge-label" x="338" y="152" text-anchor="middle">commit</text>
</g>
</g>
<!-- states -->
<g class="nodes">
<g class="node n-a" transform="translate(58,165)">
<circle class="ring" r="26"/>
<text class="n-label" y="45">idle</text>
<g class="lit">
<circle class="glow" r="26"/>
<circle class="halo" r="26"/>
<text class="n-label" y="45">idle</text>
</g>
</g>
<g class="node n-b" transform="translate(170,84)">
<circle class="ring" r="26"/>
<text class="n-label" y="-38">armed</text>
<g class="lit">
<circle class="glow" r="26"/>
<circle class="halo" r="26"/>
<text class="n-label" y="-38">armed</text>
</g>
</g>
<g class="node n-c" transform="translate(282,165)">
<circle class="ring" r="26"/>
<text class="n-label" y="45">running</text>
<g class="lit">
<circle class="glow" r="26"/>
<circle class="halo" r="26"/>
<text class="n-label" y="45">running</text>
</g>
</g>
<g class="node n-d" transform="translate(170,246)">
<circle class="ring" r="26"/>
<text class="n-label" y="46">retry</text>
<g class="lit">
<circle class="glow" r="26"/>
<circle class="halo" r="26"/>
<text class="n-label" y="46">retry</text>
</g>
</g>
<g class="node n-e" transform="translate(394,165)">
<circle class="ring" r="26"/>
<circle class="ring" r="20.5"/>
<text class="n-label" y="45">settled</text>
<g class="lit">
<circle class="glow" r="26"/>
<circle class="halo" r="26"/>
<circle class="halo" r="20.5"/>
<text class="n-label" y="45">settled</text>
</g>
</g>
</g>
<!-- the token: exactly one, always somewhere -->
<g class="token">
<circle class="token-halo" r="16"/>
<circle class="token-core" r="7"/>
</g>
</svg>
<div class="readout">
<span class="r-key">state</span>
<span class="r-val">
<i class="r-a">idle</i>
<i class="r-b">armed</i>
<i class="r-c">running</i>
<i class="r-d">retry</i>
<i class="r-e">settled</i>
</span>
<span class="r-note">deterministic</span>
</div>
</div>
<!-- ────────────────────────────────────────── KEEP TO HERE -->
</div>
</body>
</html>