Back to the library

Progress bar — indeterminate sweep

Variant: Indeterminate sweep

The bar for a wait with no number: a worm that is nothing but the interval between two edges running the identical curve out of phase — 286ms apart on the long pass, 130ms on the short — so it is widest mid-rail where the curve is steepest and collapses into both ends with no separate grow and shrink to keep in sync. Two passes per loop, one long and one short, because a single repeating transit turns into a metronome inside three cycles. The worm is clipped rather than scaled — an inset with a round joint keeps the end caps circular at every width — and the readout slot is reserved and left empty, which is the honest way to say there is no percentage to put in it.

loadingprogressloop

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>Progress bar — indeterminate sweep</title>
    <style>
      /* The worm is not an element that moves — it is the interval between a
         leading edge and a trailing edge, each a registered number on its own
         animation. Same curve, offset in time: the width is the phase gap. */
      @property --_a {
        syntax: '<number>';
        inherits: false;
        initial-value: 0;
      }
      @property --_b {
        syntax: '<number>';
        inherits: false;
        initial-value: 0;
      }

      :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) 52%, transparent);
        --_track: color-mix(in srgb, var(--oat) 55%, transparent);

        /* Clock — 2.6s carrying two passes: a long unhurried one and a short
           quick one, so the loop never settles into a metronome. */
        --cycle: 2.6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 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 {
        width: min(300px, 82vw);
        display: grid;
        gap: 10px;
      }

      .row {
        display: flex;
        justify-content: space-between;
        align-items: baseline;
        gap: 12px;
      }

      .lbl {
        font-size: 12px;
        font-weight: 600;
        letter-spacing: 0.01em;
      }

      /* The readout slot is empty on purpose: an indeterminate bar has no
         number, and reserving the space is the honest way to say so. */
      .pct {
        font-size: 12px;
        min-width: 4ch;
      }

      .rail {
        position: relative;
        height: 6px;
        border-radius: 999px;
        background: var(--_track);
        overflow: hidden;
      }

      /* Clipped, not scaled: an inset with a round joint keeps the worm's end
         caps circular at every width, where a scaleX would squash them into
         slivers as the worm collapses. */
      .worm {
        position: absolute;
        inset: 0;
        border-radius: 999px;
        background: var(--clay);
        clip-path: inset(0 calc((100 - var(--_a)) * 1%) 0 calc(var(--_b) * 1%) round 999px);
        animation:
          lead var(--t) infinite,
          chase var(--t) infinite;
      }

      /* Both edges run the identical curve — the worm is widest where that
         curve is steepest, mid-rail, and collapses into both ends without
         either edge easing differently. The resets happen on frames where the
         edges are equal, so the width is zero and there is nothing to see. */
      @keyframes lead {
        0%,
        3% {
          --_a: 0;
          animation-timing-function: var(--ease-in-out);
        }
        47%,
        63.5% {
          --_a: 100;
          animation-timing-function: linear;
        }
        64%,
        65.5% {
          --_a: 0;
          animation-timing-function: var(--ease-in-out);
        }
        87.5%,
        100% {
          --_a: 100;
        }
      }

      @keyframes chase {
        0%,
        14% {
          --_b: 0;
          animation-timing-function: var(--ease-in-out);
        }
        58%,
        63.5% {
          --_b: 100;
          animation-timing-function: linear;
        }
        64%,
        70.5% {
          --_b: 0;
          animation-timing-function: var(--ease-in-out);
        }
        92.5%,
        100% {
          --_b: 100;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The travel goes; the claim of activity stays. A centred segment
           breathes on a slow clock, and the breath never stops — a segment
           sitting still mid-rail is indistinguishable from a hung process. */
        .worm {
          animation: worm-rm calc(var(--t) * 1.6) ease-in-out infinite;
          clip-path: inset(0 32% 0 32% round 999px);
        }

        @keyframes worm-rm {
          0%,
          100% {
            opacity: 0.45;
          }
          50% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="card"
      role="img"
      aria-label="An indeterminate progress bar: a worm of colour grows out of the left end, crosses the rail, and drains into the right, in alternating long and short passes"
    >
      <div class="row">
        <span class="lbl">Working</span>
        <span class="pct"></span>
      </div>
      <div class="rail">
        <div class="worm"></div>
      </div>
    </div>
  </body>
</html>