Back to the library

Rollup — worst member wins

Variant: Worst member wins

A status header over five services that takes the worst state among them, and says whose state it took. The summary never reports "1 degraded": a count lets the failing member hide in the total, so the header reads "Queue degraded" and the queue row is tinted while it stays that way. Order carries the logic: the member turns first and the summary follows 180ms later, so whoever notices the header change finds its reason already lit below; recovery runs in the same order. Colour is a crossfade of stacked discs rather than a repaint, the label swap is 120ms out and 240ms in on ease-out with a 4px lift, and a single ring leaves the culprit at the moment it turns — it marks the change, not the state, so it does not keep pulsing while degraded. Use it for status pages, service health headers, and any badge that summarises children.

statusindicatorinfrastructureloop

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 — worst member wins</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 60ms. */
        --cycle: 6s;
        --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);
      }

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

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

      /* Every status dot is two stacked discs, olive under clay, and a state
         change is the clay one's opacity: a colour swap without repainting. */
      .dot {
        position: relative;
        flex: none;
        width: 8px;
        height: 8px;
      }

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

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

      .dot b {
        background: var(--clay);
        opacity: 0;
      }

      .summary .dot {
        width: 10px;
        height: 10px;
      }

      .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;
      }

      .label .bad {
        opacity: 0;
        color: var(--clay-d);
      }

      .members {
        list-style: none;
        padding: 4px 0;
      }

      .members li {
        position: relative;
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 0 14px;
        height: 24px;
        font-size: 12px;
        color: color-mix(in srgb, var(--slate) 70%, transparent);
      }

      /* The culprit's row tint: what the summary's words point at. */
      .members li::before {
        content: '';
        position: absolute;
        inset: 0;
        background: color-mix(in srgb, var(--clay) 10%, transparent);
        opacity: 0;
      }

      .members li > * {
        position: relative;
      }

      /* Cause before effect. The member turns at 20%; the summary follows
         180ms later at 23%, so the eye that catches the header change finds
         its reason already lit below. Recovery runs the same order. */
      .culprit .dot b,
      .culprit::before {
        animation: member var(--t) infinite;
      }

      .summary .bad {
        animation: rollup var(--t) infinite;
      }

      .summary .dot b {
        animation: rollup-dot var(--t) infinite;
      }

      .summary .ok {
        animation: rollup-ok var(--t) infinite;
      }

      /* One ring off the culprit at the moment it turns, and only then: it
         marks the change, not the state, so it does not repeat while degraded.
         Sized at full spread and scaled up from the dot, so the stroke stays a
         hairline instead of thickening as it grows. */
      .culprit .ring {
        position: absolute;
        inset: -6.4px;
        border-radius: 50%;
        border: 1.5px solid var(--clay);
        opacity: 0;
        animation: ring var(--t) infinite;
      }

      @keyframes member {
        0%,
        20% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        23.5%,
        66% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        70%,
        100% {
          opacity: 0;
        }
      }

      @keyframes rollup {
        0%,
        23% {
          opacity: 0;
          transform: translateY(4px);
          animation-timing-function: var(--ease-out);
        }
        27%,
        69% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        71%,
        100% {
          opacity: 0;
          transform: translateY(-4px);
        }
      }

      @keyframes rollup-dot {
        0%,
        23% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        27%,
        69% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        72%,
        100% {
          opacity: 0;
        }
      }

      @keyframes rollup-ok {
        0%,
        23% {
          opacity: 1;
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        25%,
        69% {
          opacity: 0;
          transform: translateY(-4px);
        }
        69.01% {
          transform: translateY(4px);
          animation-timing-function: var(--ease-out);
        }
        73%,
        100% {
          opacity: 1;
          transform: translateY(0);
        }
      }

      @keyframes ring {
        0%,
        20% {
          opacity: 0;
          transform: scale(0.385);
        }
        20.01% {
          opacity: 0.7;
          transform: scale(0.385);
          animation-timing-function: var(--ease-out);
        }
        32%,
        100% {
          opacity: 0;
          transform: scale(1);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The ring and the label lift go; the crossfades stay, at twice the
           period, because colour and words are the whole message. */
        .culprit .ring {
          display: none;
        }

        .culprit .dot b,
        .culprit::before,
        .summary .dot b,
        .summary .bad,
        .summary .ok {
          animation-duration: calc(var(--t) * 2);
        }

        .summary .bad,
        .summary .ok {
          transform: none !important;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="panel"
      role="img"
      aria-label="A status summary over five services. When the queue degrades, its own row turns amber first and the summary follows a beat later, naming the queue instead of reporting an anonymous count; when it recovers, the summary returns to all systems normal"
    >
      <div class="summary">
        <span class="dot"><i></i><b></b></span>
        <span class="label">
          <span class="ok">All systems normal</span>
          <span class="bad">Queue degraded</span>
        </span>
      </div>
      <ul class="members">
        <li>
          <span>API</span><span class="dot"><i></i><b></b></span>
        </li>
        <li>
          <span>Web app</span><span class="dot"><i></i><b></b></span>
        </li>
        <li>
          <span>Database</span><span class="dot"><i></i><b></b></span>
        </li>
        <li class="culprit">
          <span>Queue</span><span class="dot"><i></i><b></b><span class="ring"></span></span>
        </li>
        <li>
          <span>CDN</span><span class="dot"><i></i><b></b></span>
        </li>
      </ul>
    </div>
  </body>
</html>

The variant family

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