Back to the library

Rollup — the straggler

Variant: The straggler

Six parallel checks and the one that is holding everyone up. Every bar is the same full-width fill sweeping on a single linear clock, clipped where its own job finished, so equal elapsed time is equal length on every row and the slowest job is literally the long pole — linear because x is wall time, and an eased sweep would claim the run went faster in the middle. Passes land as an olive tick tweened through one registered number per row, which drives the glyph and the bar together so they cannot disagree. Once the fifth pass lands they step back to 55%, and 180ms later the header stops saying "Running 6 checks" and starts saying "Waiting on e2e": a rollup that read "5/6" would let the last job hide in the total. When it lands, everything returns and the summary turns olive. Use it for CI checks, parallel uploads, fan-out jobs, and any aggregate whose finish time is set by its slowest member.

statusprogressinfrastructureloop

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>Rollup — the straggler</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;
        --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)
        );

        /* Clock — every duration in the document runs off --t. 1% is 70ms.
           The run is 8%–68%: 4.2s of wall time shared by every job. */
        --cycle: 7s;
        --t: calc(var(--cycle) / var(--speed));
        /* The clock every animation reads; reduced motion doubles it in one
           place, so no selector can fall out of step with the rest. */
        --_t: var(--t);

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

      /* 0 while a job runs, 1 once it has passed. Registered so it tweens,
         and inherited so one keyframe per row drives its glyph and its bar. */
      @property --_done {
        syntax: '<number>';
        inherits: true;
        initial-value: 0;
      }

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

      .panel {
        width: min(264px, 88vw);
        background: var(--paper);
        border: 1px solid var(--oat);
        border-radius: calc(10px * var(--round));
        overflow: hidden;
      }

      .rig {
        animation: rig var(--_t) infinite;
      }

      .summary {
        display: flex;
        align-items: center;
        gap: 10px;
        padding: 11px 14px;
        border-bottom: 1px solid var(--oat);
      }

      .dot {
        position: relative;
        flex: none;
        width: 10px;
        height: 10px;
      }

      .dot i,
      .dot b {
        position: absolute;
        inset: 0;
        border-radius: 50%;
      }

      .dot i {
        background: var(--clay);
      }

      .dot b {
        background: var(--olive);
        opacity: 0;
        animation: settle var(--_t) infinite;
      }

      .label {
        position: relative;
        flex: 1;
        height: 18px;
        font-size: 13px;
        font-weight: 600;
        line-height: 18px;
      }

      .label span {
        position: absolute;
        inset: 0;
        white-space: nowrap;
        opacity: 0;
      }

      .label .l0 {
        animation: l0 var(--_t) infinite;
      }

      .label .l1 {
        animation: l1 var(--_t) infinite;
      }

      .label .l2 {
        color: color-mix(in oklab, var(--olive), var(--slate) 35%);
        animation: l2 var(--_t) infinite;
      }

      .rows {
        list-style: none;
        padding: 6px 14px 8px;
      }

      .rows li {
        display: grid;
        grid-template-columns: 10px 44px 1fr;
        align-items: center;
        gap: 8px;
        height: 22px;
        font-size: 12px;
        color: color-mix(in srgb, var(--slate) 72%, transparent);
      }

      .rows li:not(.e2e) {
        animation:
          var(--_k) var(--_t) infinite,
          dim var(--_t) infinite;
      }

      .rows .e2e {
        animation: d-e2e var(--_t) infinite;
      }

      /* Running is a clay dot; passed is an olive tick that lands on it. */
      .glyph {
        position: relative;
        width: 10px;
        height: 10px;
      }

      .glyph i {
        position: absolute;
        inset: 1px;
        border-radius: 50%;
        background: var(--clay);
        opacity: calc(1 - var(--_done));
      }

      .glyph path {
        fill: none;
        stroke: var(--olive);
        stroke-width: 1.6;
        stroke-linecap: round;
        stroke-linejoin: round;
      }

      .glyph svg {
        position: absolute;
        inset: 0;
        width: 10px;
        height: 10px;
        opacity: var(--_done);
        transform: scale(calc(0.9 + 0.1 * var(--_done)));
      }

      /* Every bar is the same clock: a full-width fill sweeping linearly
         through the run, clipped at the point its own job finished. One
         keyframe, so equal elapsed time is equal length on every row — the
         straggler is literally the long pole. */
      .lane {
        position: relative;
        height: 6px;
      }

      .clip {
        display: block;
        width: calc(var(--_f) * 100%);
        height: 100%;
        overflow: hidden;
        border-radius: 3px;
      }

      .fill {
        display: block;
        position: relative;
        width: calc(100% / var(--_f));
        height: 100%;
        transform: scaleX(0);
        transform-origin: left;
        animation: elapsed var(--_t) linear infinite;
      }

      .fill::before,
      .fill::after {
        content: '';
        position: absolute;
        inset: 0;
      }

      .fill::before {
        background: color-mix(in srgb, var(--slate) 18%, transparent);
      }

      .fill::after {
        background: var(--olive);
        opacity: calc(var(--_done) * 0.7);
      }

      @keyframes rig {
        0% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        4%,
        92% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        95%,
        100% {
          opacity: 0;
        }
      }

      @keyframes elapsed {
        0%,
        8% {
          transform: scaleX(0);
        }
        68%,
        95% {
          transform: scaleX(1);
        }
        96%,
        100% {
          transform: scaleX(0);
        }
      }

      /* Each job passes at 8% + 60% x its share of the run. */
      @keyframes d-lint {
        0%,
        21.2% {
          --_done: 0;
          animation-timing-function: var(--ease-out);
        }
        24.2%,
        95% {
          --_done: 1;
        }
        96%,
        100% {
          --_done: 0;
        }
      }

      @keyframes d-a11y {
        0%,
        28.4% {
          --_done: 0;
          animation-timing-function: var(--ease-out);
        }
        31.4%,
        95% {
          --_done: 1;
        }
        96%,
        100% {
          --_done: 0;
        }
      }

      @keyframes d-build {
        0%,
        32% {
          --_done: 0;
          animation-timing-function: var(--ease-out);
        }
        35%,
        95% {
          --_done: 1;
        }
        96%,
        100% {
          --_done: 0;
        }
      }

      @keyframes d-types {
        0%,
        36.8% {
          --_done: 0;
          animation-timing-function: var(--ease-out);
        }
        39.8%,
        95% {
          --_done: 1;
        }
        96%,
        100% {
          --_done: 0;
        }
      }

      @keyframes d-unit {
        0%,
        41.6% {
          --_done: 0;
          animation-timing-function: var(--ease-out);
        }
        44.6%,
        95% {
          --_done: 1;
        }
        96%,
        100% {
          --_done: 0;
        }
      }

      @keyframes d-e2e {
        0%,
        68% {
          --_done: 0;
          animation-timing-function: var(--ease-out);
        }
        71%,
        95% {
          --_done: 1;
        }
        96%,
        100% {
          --_done: 0;
        }
      }

      /* Once the fifth pass has landed, the passed ones step back and the
         header names the one still running — 180ms behind the tick, so the
         cause is on screen before the summary changes. A rollup that only said
         "5/6" would let the last job hide in the total. */
      @keyframes dim {
        0%,
        44.6% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        48%,
        70.5% {
          opacity: 0.55;
          animation-timing-function: var(--ease-out);
        }
        74.5%,
        100% {
          opacity: 1;
        }
      }

      @keyframes l0 {
        0%,
        44.1% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        46.1%,
        95% {
          opacity: 0;
          transform: translateY(-4px);
        }
        96%,
        100% {
          opacity: 1;
          transform: translateY(0);
        }
      }

      @keyframes l1 {
        0%,
        45.1% {
          opacity: 0;
          transform: translateY(4px);
          animation-timing-function: var(--ease-out);
        }
        48.5%,
        70.5% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        72.5%,
        100% {
          opacity: 0;
          transform: translateY(-4px);
        }
      }

      @keyframes l2 {
        0%,
        71.5% {
          opacity: 0;
          transform: translateY(4px);
          animation-timing-function: var(--ease-out);
        }
        74.9%,
        95% {
          opacity: 1;
          transform: translateY(0);
        }
        96%,
        100% {
          opacity: 0;
          transform: translateY(4px);
        }
      }

      @keyframes settle {
        0%,
        70.5% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        73.5%,
        95% {
          opacity: 1;
        }
        96%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The bars stand at their final lengths — the comparison survives
           without the sweep — and the passes, dimming and header still run,
           crossfading at twice the period. */
        .fill {
          animation: none;
          transform: none;
        }

        :root {
          --_t: calc(var(--t) * 2);
        }

        .label span {
          transform: none !important;
        }

        .glyph svg {
          transform: none;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="panel"
      role="img"
      aria-label="Six checks run in parallel, each with a bar measuring its elapsed time. Five pass at different moments; when only end-to-end tests remain, the passed rows dim and the summary says waiting on e2e by name. When it finally passes, the summary reads all checks passed"
    >
      <div class="rig">
        <div class="summary">
          <span class="dot"><i></i><b></b></span>
          <span class="label">
            <span class="l0">Running 6 checks</span>
            <span class="l1">Waiting on e2e</span>
            <span class="l2">All checks passed</span>
          </span>
        </div>
        <ul class="rows">
          <li style="--_k: d-lint; --_f: 0.22">
            <span class="glyph"
              ><i></i
              ><svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 5.3 4 7.4 8.2 2.8" /></svg
            ></span>
            <span>lint</span>
            <span class="lane"
              ><span class="clip"><span class="fill"></span></span
            ></span>
          </li>
          <li style="--_k: d-types; --_f: 0.48">
            <span class="glyph"
              ><i></i
              ><svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 5.3 4 7.4 8.2 2.8" /></svg
            ></span>
            <span>types</span>
            <span class="lane"
              ><span class="clip"><span class="fill"></span></span
            ></span>
          </li>
          <li style="--_k: d-a11y; --_f: 0.34">
            <span class="glyph"
              ><i></i
              ><svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 5.3 4 7.4 8.2 2.8" /></svg
            ></span>
            <span>a11y</span>
            <span class="lane"
              ><span class="clip"><span class="fill"></span></span
            ></span>
          </li>
          <li style="--_k: d-unit; --_f: 0.56">
            <span class="glyph"
              ><i></i
              ><svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 5.3 4 7.4 8.2 2.8" /></svg
            ></span>
            <span>unit</span>
            <span class="lane"
              ><span class="clip"><span class="fill"></span></span
            ></span>
          </li>
          <li style="--_k: d-build; --_f: 0.4">
            <span class="glyph"
              ><i></i
              ><svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 5.3 4 7.4 8.2 2.8" /></svg
            ></span>
            <span>build</span>
            <span class="lane"
              ><span class="clip"><span class="fill"></span></span
            ></span>
          </li>
          <li class="e2e" style="--_f: 1">
            <span class="glyph"
              ><i></i
              ><svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 5.3 4 7.4 8.2 2.8" /></svg
            ></span>
            <span>e2e</span>
            <span class="lane"
              ><span class="clip"><span class="fill"></span></span
            ></span>
          </li>
        </ul>
      </div>
    </div>
  </body>
</html>

The variant family

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