Back to the library

Sync — autosave debounce

Variant: Autosave debounce

An editor chip crossing the whole save lifecycle — edit, write, tick — and the piece is the 900ms in between, where a hairline ring drains around the dirty dot before anything is written. That wait is the debounce every autosave implements and almost none of them show; drawn, it converts "why has it not saved yet" into "it is waiting for me to stop". The ring and the spinner both run linear, because a timer is a clock and a write in flight is machine motion, and neither answers a person — while the two moments that do, the dot popping in on the edit and the tick landing, get the deceleration. Then the chip falls to half weight: autosave fires dozens of times an hour, and a state seen that often has earned near-silence. The loop opens and closes at rest, so there is no reset to hide.

statussuccessloopsvg

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 — autosave debounce</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 — 5.6s: an edit at 6%, a 0.9s debounce, a 1s write, a tick, and
           a long quiet tail. The loop opens and closes in the resting Saved
           state, so there is no reset to hide anywhere in it. */
        --cycle: 5.6s;
        --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);
      }

      /* A pill — its radius is half its own height, so it stays literal. */
      .chip {
        display: inline-flex;
        align-items: center;
        gap: 8px;
        padding: 7px 13px 7px 10px;
        border-radius: 999px;
        background: var(--paper);
        border: 1px solid var(--oat);
        animation: chip var(--t) ease infinite;
      }

      /* Half weight at rest: autosave fires dozens of times an hour, and a
         state seen that often has earned near-silence between events. */
      @keyframes chip {
        0%,
        6% {
          opacity: 0.62;
        }
        8.5%,
        61% {
          opacity: 1;
        }
        66%,
        100% {
          opacity: 0.62;
        }
      }

      .glyph {
        width: 16px;
        height: 16px;
        flex: none;
        overflow: visible;
      }

      .dot {
        fill: var(--clay);
        transform-origin: 8px 8px;
        animation: dot var(--t) ease infinite;
      }

      @keyframes dot {
        0%,
        6% {
          opacity: 0;
          transform: scale(0.9);
          animation-timing-function: var(--ease-out);
        }
        9.5%,
        25.5% {
          opacity: 1;
          transform: scale(1);
          animation-timing-function: ease;
        }
        27%,
        100% {
          opacity: 0;
          transform: scale(0.9);
        }
      }

      /* The debounce made visible: a hairline timer draining around the dirty
         dot. It runs linear because it is a clock, and a clock does not ease. */
      .ring {
        fill: none;
        stroke: color-mix(in srgb, var(--clay) 55%, transparent);
        stroke-width: 1.6;
        stroke-dasharray: 39;
        transform: rotate(-90deg);
        transform-origin: 8px 8px;
        animation:
          ring-drain var(--t) linear infinite,
          ring-fade var(--t) ease infinite;
      }

      @keyframes ring-drain {
        0%,
        10% {
          stroke-dashoffset: 0;
        }
        26%,
        100% {
          stroke-dashoffset: 39;
        }
      }

      @keyframes ring-fade {
        0%,
        7% {
          opacity: 0;
        }
        8.5%,
        25.5% {
          opacity: 1;
        }
        27%,
        100% {
          opacity: 0;
        }
      }

      /* The write in flight. Also linear: machine motion answers to no one. */
      .arc {
        fill: none;
        stroke: var(--clay);
        stroke-width: 2;
        stroke-linecap: round;
        stroke-dasharray: 27 12;
        transform-origin: 8px 8px;
        animation:
          arc-spin var(--t) linear infinite,
          arc-fade var(--t) ease infinite;
      }

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

      @keyframes arc-fade {
        0%,
        25.5% {
          opacity: 0;
        }
        27%,
        43% {
          opacity: 1;
        }
        45%,
        100% {
          opacity: 0;
        }
      }

      .tick {
        fill: none;
        stroke: var(--clay-d);
        stroke-width: 2;
        stroke-linecap: round;
        stroke-linejoin: round;
        stroke-dasharray: 11.5;
        animation:
          tick-draw var(--t) linear infinite,
          tick-fade var(--t) ease infinite;
      }

      @keyframes tick-draw {
        0%,
        8% {
          stroke-dashoffset: 0;
        }
        20%,
        44.5% {
          stroke-dashoffset: 11.5;
          animation-timing-function: var(--ease-accent);
        }
        49.5%,
        100% {
          stroke-dashoffset: 0;
        }
      }

      @keyframes tick-fade {
        0%,
        6% {
          opacity: 1;
        }
        7.5%,
        44% {
          opacity: 0;
        }
        45.5%,
        100% {
          opacity: 1;
        }
      }

      /* Three labels share one grid cell: the chip is as wide as the longest
         and never reflows while they trade places. */
      .lbl {
        display: grid;
        font-size: 12px;
        font-weight: 600;
        letter-spacing: 0.01em;
      }

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

      .edited {
        color: color-mix(in srgb, var(--clay-d) 72%, var(--slate));
        animation: edited var(--t) ease infinite;
      }

      @keyframes edited {
        0%,
        6% {
          opacity: 0;
          transform: translateY(4px);
          animation-timing-function: var(--ease-out);
        }
        9%,
        25.5% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: ease;
        }
        27.5%,
        100% {
          opacity: 0;
          transform: translateY(-4px);
        }
      }

      .saving {
        color: var(--_muted);
        animation: saving var(--t) ease infinite;
      }

      @keyframes saving {
        0%,
        25.5% {
          opacity: 0;
          transform: translateY(4px);
          animation-timing-function: var(--ease-out);
        }
        28%,
        43.5% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: ease;
        }
        45.5%,
        100% {
          opacity: 0;
          transform: translateY(-4px);
        }
      }

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

      @keyframes saved {
        0%,
        6% {
          opacity: 1;
          transform: translateY(0);
        }
        8%,
        30% {
          opacity: 0;
          transform: translateY(-4px);
        }
        /* Repositioned below while hidden, so the next entrance rises. */
        31%,
        44% {
          opacity: 0;
          transform: translateY(4px);
          animation-timing-function: var(--ease-out);
        }
        48%,
        100% {
          opacity: 1;
          transform: translateY(0);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Gentler, not dead: the state changes crossfade, the timer ring keeps
           draining (it is the datum), and the spinner becomes a slow blink
           instead of a rotation. One shared 1.6x clock keeps them in step. */
        .chip,
        .dot,
        .ring,
        .arc,
        .tick,
        .edited,
        .saving,
        .saved {
          animation-duration: calc(var(--t) * 1.6);
        }

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

        @keyframes dot-rm {
          0%,
          6% {
            opacity: 0;
          }
          9.5%,
          25.5% {
            opacity: 1;
          }
          27%,
          100% {
            opacity: 0;
          }
        }

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

        @keyframes arc-rm {
          0%,
          25.5% {
            opacity: 0;
          }
          28%,
          31% {
            opacity: 1;
          }
          35% {
            opacity: 0.55;
          }
          39%,
          43% {
            opacity: 1;
          }
          45%,
          100% {
            opacity: 0;
          }
        }

        .tick {
          animation-name: tick-fade;
          stroke-dashoffset: 0;
        }

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

        @keyframes edited-rm {
          0%,
          6% {
            opacity: 0;
          }
          9%,
          25.5% {
            opacity: 1;
          }
          27.5%,
          100% {
            opacity: 0;
          }
        }

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

        @keyframes saving-rm {
          0%,
          25.5% {
            opacity: 0;
          }
          28%,
          43.5% {
            opacity: 1;
          }
          45.5%,
          100% {
            opacity: 0;
          }
        }

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

        @keyframes saved-rm {
          0%,
          6% {
            opacity: 1;
          }
          8%,
          44% {
            opacity: 0;
          }
          48%,
          100% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <span
      class="chip"
      role="img"
      aria-label="An autosave chip: an edit marks the document dirty, a timer ring drains through the debounce, a spinner writes, and a tick lands on Saved"
    >
      <svg class="glyph" viewBox="0 0 16 16" aria-hidden="true">
        <circle class="ring" cx="8" cy="8" r="6.2" />
        <circle class="arc" cx="8" cy="8" r="6.2" />
        <circle class="dot" cx="8" cy="8" r="3.2" />
        <path class="tick" d="M4.5 8.5 L7.2 11 L11.5 5.2" />
      </svg>
      <span class="lbl">
        <span class="edited">Edited</span>
        <span class="saving">Saving</span>
        <span class="saved">Saved</span>
      </span>
    </span>
  </body>
</html>

The variant family

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