Back to the library

Sync — queue drain

Variant: Queue drain

Offline edits going up once the connection is back: four queued changes leave on a steady 1.02s beat, and each departure leaves its slot's oat outline behind, so remaining work stays countable against total capacity at a glance — the queue and the meter are the same object. The count beside it is a registered integer that ticks down only after its pip has fully cleared the row, because the number is the outcome of an upload rather than the intention; sequence it the other way and the counter claims work the wire has not finished. At zero the label trades for a drawn tick on the same quiet curve the pips left on. Reach for it in anything local-first — notes, drafts, field apps — where the user needs to watch a backlog shrink rather than take a spinner's word for it.

statuscounterdataloop

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 — queue drain</title>
    <style>
      @property --_n {
        syntax: '<integer>';
        inherits: true;
        initial-value: 4;
      }

      :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;
        --round: 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 — 6.4s: four uploads on a steady 1.02s beat, a tick, a rest. The
           chip resets inside a dark window at the wrap, so the pips are never
           seen returning to their slots. */
        --cycle: 6.4s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-accent: cubic-bezier(0.32, 0.72, 0, 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);
      }

      .card {
        display: inline-flex;
        align-items: center;
        gap: 12px;
        padding: 11px 15px;
        border-radius: calc(10px * var(--round));
        background: var(--paper);
        border: 1px solid var(--oat);
        animation: card var(--t) linear infinite;
      }

      @keyframes card {
        0% {
          opacity: 0;
        }
        2.5% {
          opacity: 1;
        }
        95% {
          opacity: 1;
          animation-timing-function: ease;
        }
        100% {
          opacity: 0;
        }
      }

      /* Each slot keeps its oat outline after its pip has gone: remaining work
         stays countable against total capacity, so the row is the meter. */
      .slots {
        display: flex;
        gap: 6px;
      }

      .slot {
        position: relative;
        width: 9px;
        height: 9px;
        border-radius: 50%;
        box-shadow: inset 0 0 0 1.5px var(--oat);
      }

      .slot i {
        position: absolute;
        inset: 0;
        border-radius: 50%;
        background: var(--clay);
      }

      /* Departures on a steady 1.02s cadence — uploads are a queue being
         worked, not a burst — each one a 290ms rise that decelerates away. */
      .slot:nth-child(1) i {
        animation: pip1 var(--t) ease infinite;
      }
      .slot:nth-child(2) i {
        animation: pip2 var(--t) ease infinite;
      }
      .slot:nth-child(3) i {
        animation: pip3 var(--t) ease infinite;
      }
      .slot:nth-child(4) i {
        animation: pip4 var(--t) ease infinite;
      }

      @keyframes pip1 {
        0%,
        12% {
          opacity: 1;
          transform: none;
          animation-timing-function: var(--ease-out);
        }
        16.5%,
        100% {
          opacity: 0;
          transform: translateY(-10px);
        }
      }

      @keyframes pip2 {
        0%,
        28% {
          opacity: 1;
          transform: none;
          animation-timing-function: var(--ease-out);
        }
        32.5%,
        100% {
          opacity: 0;
          transform: translateY(-10px);
        }
      }

      @keyframes pip3 {
        0%,
        44% {
          opacity: 1;
          transform: none;
          animation-timing-function: var(--ease-out);
        }
        48.5%,
        100% {
          opacity: 0;
          transform: translateY(-10px);
        }
      }

      @keyframes pip4 {
        0%,
        60% {
          opacity: 1;
          transform: none;
          animation-timing-function: var(--ease-out);
        }
        64.5%,
        100% {
          opacity: 0;
          transform: translateY(-10px);
        }
      }

      /* Two labels share one grid cell so the card never reflows. */
      .track {
        display: grid;
        font-size: 12px;
        font-weight: 600;
        letter-spacing: 0.01em;
      }

      .track > span {
        grid-area: 1 / 1;
        display: flex;
        align-items: center;
        gap: 5px;
        white-space: nowrap;
      }

      .q {
        color: var(--_muted);
        font-variant-numeric: tabular-nums;
        animation:
          count var(--t) linear infinite,
          q-fade var(--t) ease infinite;
      }

      /* The count decrements only after its pip has fully cleared the row:
         the number is the outcome of an upload, not the intention. */
      .q b::before {
        counter-reset: q var(--_n);
        content: counter(q);
        font-weight: 700;
        color: var(--slate);
      }

      @keyframes count {
        0%,
        16.6% {
          --_n: 4;
        }
        17%,
        32.6% {
          --_n: 3;
        }
        33%,
        48.6% {
          --_n: 2;
        }
        49%,
        64.6% {
          --_n: 1;
        }
        65%,
        100% {
          --_n: 0;
        }
      }

      @keyframes q-fade {
        0%,
        66% {
          opacity: 1;
          transform: translateY(0);
        }
        68.5%,
        100% {
          opacity: 0;
          transform: translateY(-4px);
        }
      }

      .s {
        font-weight: 600;
        animation: s-fade var(--t) ease infinite;
      }

      @keyframes s-fade {
        0%,
        66.5% {
          opacity: 0;
          transform: translateY(4px);
          animation-timing-function: var(--ease-out);
        }
        69.5%,
        100% {
          opacity: 1;
          transform: translateY(0);
        }
      }

      .s svg {
        width: 12px;
        height: 12px;
        flex: none;
        overflow: visible;
      }

      .s path {
        fill: none;
        stroke: var(--clay-d);
        stroke-width: 2;
        stroke-linecap: round;
        stroke-linejoin: round;
        stroke-dasharray: 15;
        animation: s-draw var(--t) linear infinite;
      }

      @keyframes s-draw {
        0%,
        67% {
          stroke-dashoffset: 15;
          animation-timing-function: var(--ease-accent);
        }
        71%,
        100% {
          stroke-dashoffset: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The queue still drains — the emptying slots are the meaning — but
           each pip fades in place instead of launching, and the tick appears
           already drawn. One shared 1.6x clock keeps the count in step. */
        .card,
        .slot i,
        .q,
        .s,
        .s path {
          animation-duration: calc(var(--t) * 1.6);
        }

        .slot:nth-child(1) i {
          animation-name: pip1-rm;
          transform: none;
        }
        .slot:nth-child(2) i {
          animation-name: pip2-rm;
          transform: none;
        }
        .slot:nth-child(3) i {
          animation-name: pip3-rm;
          transform: none;
        }
        .slot:nth-child(4) i {
          animation-name: pip4-rm;
          transform: none;
        }

        @keyframes pip1-rm {
          0%,
          12% {
            opacity: 1;
          }
          16.5%,
          100% {
            opacity: 0;
          }
        }
        @keyframes pip2-rm {
          0%,
          28% {
            opacity: 1;
          }
          32.5%,
          100% {
            opacity: 0;
          }
        }
        @keyframes pip3-rm {
          0%,
          44% {
            opacity: 1;
          }
          48.5%,
          100% {
            opacity: 0;
          }
        }
        @keyframes pip4-rm {
          0%,
          60% {
            opacity: 1;
          }
          64.5%,
          100% {
            opacity: 0;
          }
        }

        .q {
          animation-name: count, q-rm;
        }

        @keyframes q-rm {
          0%,
          66% {
            opacity: 1;
          }
          68.5%,
          100% {
            opacity: 0;
          }
        }

        .s {
          animation-name: s-rm;
          transform: none;
        }

        @keyframes s-rm {
          0%,
          66.5% {
            opacity: 0;
          }
          69.5%,
          100% {
            opacity: 1;
          }
        }

        .s path {
          animation: none;
          stroke-dashoffset: 0;
        }
      }
    </style>
  </head>
  <body>
    <span
      class="card"
      role="img"
      aria-label="A sync chip working through four queued changes: pips leave their slots one at a time, the count falls to zero, and a tick confirms the queue is synced"
    >
      <span class="slots">
        <span class="slot"><i></i></span>
        <span class="slot"><i></i></span>
        <span class="slot"><i></i></span>
        <span class="slot"><i></i></span>
      </span>
      <span class="track">
        <span class="q"><b></b> queued</span>
        <span class="s">
          <svg viewBox="0 0 16 16" aria-hidden="true">
            <path d="M3 8.5 L6.5 12 L13 4.5" />
          </svg>
          synced
        </span>
      </span>
    </span>
  </body>
</html>

The variant family

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