Back to the library

Sync — offline backoff

Variant: Offline backoff

The user-facing half of the story this library's retry diagrams tell as infrastructure: a connection pill drops to rust and a countdown rail under it drains toward the next attempt — linear, because a scheduled retry is a clock and easing it would misreport the wait. The first attempt fails, and the argument lands in what happens next: the rail re-arms and drains again at exactly half the rate. Showing the doubled wait beats a pill that just says "reconnecting…", because the honest answer to "is it stuck?" is a visible schedule. The failure itself gets no shake and no apology — rust simply returns, since the rail already says what happens next — and the reconnect gets the file's one deceleration, because coming back is the only moment here that answers the person watching.

statuserrorinfrastructureloop

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>Sync — offline backoff</title>
    <style>
      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        --_muted: color-mix(in srgb, var(--slate) 62%, transparent);

        /* Clock — 8s: the connection drops at 5%, a 0.96s wait, a failed
           attempt, a 1.92s wait — the same rail draining at half the rate —
           then the attempt that lands. Opens and closes connected, so the
           loop needs no reset. */
        --cycle: 8s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      }

      * {
        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);
      }

      .wrap {
        display: grid;
        gap: 7px;
        animation: wrap var(--t) ease infinite;
      }

      /* Quiet while connected, full weight the moment it is not: the pill is
         chrome, and chrome only earns attention when something is wrong. */
      @keyframes wrap {
        0%,
        5% {
          opacity: 0.72;
        }
        6.5%,
        66% {
          opacity: 1;
        }
        72%,
        100% {
          opacity: 0.72;
        }
      }

      .pill {
        display: inline-flex;
        align-items: center;
        gap: 8px;
        padding: 7px 13px 7px 11px;
        border-radius: 999px;
        background: var(--paper);
        border: 1px solid var(--oat);
      }

      .glyph {
        width: 12px;
        height: 12px;
        flex: none;
        position: relative;
      }

      .glyph > * {
        position: absolute;
        inset: 0;
      }

      .d-olive,
      .d-rust {
        margin: 2px;
        border-radius: 50%;
      }

      .d-olive {
        background: var(--olive);
        animation: olive var(--t) ease infinite;
      }

      /* The reconnect gets the file's one deceleration: coming back is the
         moment that answers the person watching. */
      @keyframes olive {
        0%,
        5% {
          opacity: 1;
          transform: scale(1);
        }
        6.2%,
        58.5% {
          opacity: 0;
          transform: scale(0.9);
          animation-timing-function: var(--ease-out);
        }
        61.5%,
        100% {
          opacity: 1;
          transform: scale(1);
        }
      }

      /* The drop is sharp and the failure is silent: rust just returns, with
         no shake and no apology — the rail already says what happens next. */
      .d-rust {
        background: var(--rust);
        opacity: 0;
        animation: rust var(--t) ease infinite;
      }

      @keyframes rust {
        0%,
        5% {
          opacity: 0;
        }
        5.9%,
        19% {
          opacity: 1;
        }
        20%,
        26% {
          opacity: 0;
        }
        27.2%,
        52% {
          opacity: 1;
        }
        53.2%,
        100% {
          opacity: 0;
        }
      }

      .d-arc {
        width: 12px;
        height: 12px;
        overflow: visible;
      }

      .d-arc circle {
        fill: none;
        stroke: var(--clay);
        stroke-width: 2;
        stroke-linecap: round;
        stroke-dasharray: 20 8.3;
        transform-origin: 6px 6px;
        animation:
          arc-spin var(--t) linear infinite,
          arc-fade var(--t) ease infinite;
      }

      @keyframes arc-spin {
        0%,
        19.5% {
          transform: rotate(-90deg);
        }
        26% {
          transform: rotate(630deg);
        }
        52.3% {
          transform: rotate(630deg);
        }
        58.6%,
        100% {
          transform: rotate(1350deg);
        }
      }

      @keyframes arc-fade {
        0%,
        19.5% {
          opacity: 0;
        }
        20.3%,
        25.3% {
          opacity: 1;
        }
        26.2%,
        52.3% {
          opacity: 0;
        }
        53.1%,
        58% {
          opacity: 1;
        }
        58.8%,
        100% {
          opacity: 0;
        }
      }

      /* Three labels share one grid cell; the pill never reflows. */
      .lbl {
        display: grid;
        font-size: 12px;
        font-weight: 600;
        letter-spacing: 0.01em;
      }

      .lbl span {
        grid-area: 1 / 1;
        white-space: nowrap;
      }

      .connected {
        animation: connected var(--t) ease infinite;
      }

      @keyframes connected {
        0%,
        5% {
          opacity: 1;
        }
        /* In step with the olive dot's 58.5–61.5% ramp, so the label cell is
           never empty at the one moment someone is actually watching it. */
        6.2%,
        58.8% {
          opacity: 0;
        }
        61.5%,
        100% {
          opacity: 1;
        }
      }

      .offline {
        color: var(--rust);
        opacity: 0;
        animation: offline var(--t) ease infinite;
      }

      @keyframes offline {
        0%,
        5% {
          opacity: 0;
        }
        5.9%,
        19% {
          opacity: 1;
        }
        20%,
        26% {
          opacity: 0;
        }
        27.2%,
        52% {
          opacity: 1;
        }
        53.2%,
        100% {
          opacity: 0;
        }
      }

      .retrying {
        color: var(--_muted);
        opacity: 0;
        animation: retrying var(--t) ease infinite;
      }

      @keyframes retrying {
        0%,
        19.7% {
          opacity: 0;
        }
        20.5%,
        25.5% {
          opacity: 1;
        }
        26.4%,
        52.5% {
          opacity: 0;
        }
        53.3%,
        58% {
          opacity: 1;
        }
        58.9%,
        100% {
          opacity: 0;
        }
      }

      /* The countdown to the next attempt. Linear, because a scheduled retry
         is a clock and easing it would misreport the wait. The second drain
         covers the same rail in twice the time — the backoff, visible. */
      .rail {
        height: 2px;
        border-radius: 1px;
        background: color-mix(in srgb, var(--oat) 55%, transparent);
        overflow: hidden;
      }

      .rail i {
        display: block;
        height: 100%;
        border-radius: 1px;
        background: var(--rust);
        transform-origin: left center;
        animation:
          rail-drain var(--t) linear infinite,
          rail-fade var(--t) ease infinite;
      }

      @keyframes rail-drain {
        0%,
        7% {
          transform: scaleX(1);
        }
        19%,
        26.7% {
          transform: scaleX(0);
        }
        /* Re-armed while hidden: a new timer, not the old one refilling. */
        27%,
        28% {
          transform: scaleX(1);
        }
        52%,
        100% {
          transform: scaleX(0);
        }
      }

      @keyframes rail-fade {
        0%,
        6% {
          opacity: 0;
        }
        7%,
        25.8% {
          opacity: 1;
        }
        26.4%,
        27.2% {
          opacity: 0;
        }
        28%,
        52.5% {
          opacity: 1;
        }
        54%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The two countdown drains stay — they are the datum, and the piece
           without them says nothing — while the dots stop scaling and the
           spinner becomes a slow blink. One shared 1.6x clock. */
        .wrap,
        .d-olive,
        .d-rust,
        .d-arc circle,
        .connected,
        .offline,
        .retrying,
        .rail i {
          animation-duration: calc(var(--t) * 1.6);
        }

        .d-olive {
          animation-name: olive-rm;
          transform: none;
        }

        @keyframes olive-rm {
          0%,
          5% {
            opacity: 1;
          }
          6.2%,
          58.5% {
            opacity: 0;
          }
          61.5%,
          100% {
            opacity: 1;
          }
        }

        .d-arc circle {
          animation-name: arc-rm;
          transform: rotate(-90deg);
        }

        @keyframes arc-rm {
          0%,
          19.5% {
            opacity: 0;
          }
          21%,
          22.8% {
            opacity: 1;
          }
          23.9% {
            opacity: 0.5;
          }
          25%,
          25.6% {
            opacity: 1;
          }
          26.2%,
          52.3% {
            opacity: 0;
          }
          54%,
          55.5% {
            opacity: 1;
          }
          56.6% {
            opacity: 0.5;
          }
          57.6%,
          58.2% {
            opacity: 1;
          }
          58.8%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="wrap"
      role="img"
      aria-label="A connection pill drops offline, a countdown rail drains to a retry that fails, drains again at half the rate, and the second attempt reconnects"
    >
      <span class="pill">
        <span class="glyph">
          <i class="d-olive"></i>
          <i class="d-rust"></i>
          <svg class="d-arc" viewBox="0 0 12 12" aria-hidden="true">
            <circle cx="6" cy="6" r="4.5" />
          </svg>
        </span>
        <span class="lbl">
          <span class="connected">Connected</span>
          <span class="offline">Offline</span>
          <span class="retrying">Retrying</span>
        </span>
      </span>
      <span class="rail"><i></i></span>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.