Back to the library

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.

conceptstate-machinegraphdiagramsvgsystems

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 &amp; 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>