Back to the library

Rollup — named quorum

Variant: Named quorum

Two of three approvals, where the bar that counts them is built out of the people. Each reviewer owns the segment under their own face, so the total is visibly a sum of named contributions rather than an anonymous fill. An approval is a ~200ms tick stamped in from scale(0.9) on ease-out; the reviewer’s segment follows 64ms later and fills in 384ms, because the signature is the cause and the bar is the bookkeeping. The count and the status both step when a segment lands rather than on the click — 1 of 2 is a state, not a quantity in transit, and quorum is called on the record — and while it is short, the status names who could still close it ("Waiting on Ana or Mo") instead of counting heads. The reviewer who never signed steps back to 55% once they are no longer blocking, with their empty segment still on the card instead of disappearing. Reach for it for code review, multi-party sign-off, and any threshold met by people rather than by a number.

statuspresenceprogressloop

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 — named quorum</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 64ms. */
        --cycle: 6.4s;
        --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);
      }

      * {
        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(252px, 86vw);
        padding: 14px 16px 16px;
        background: var(--paper);
        border: 1px solid var(--oat);
        border-radius: calc(10px * var(--round));
      }

      .rig {
        display: grid;
        gap: 14px;
        animation: rig var(--_t) infinite;
      }

      .head {
        display: flex;
        justify-content: space-between;
        font-size: 12px;
        color: color-mix(in srgb, var(--slate) 60%, transparent);
      }

      /* Stacked readouts, each cut in on its frame: a count is a state, so
         it steps rather than tweens. */
      .stack {
        position: relative;
        display: inline-block;
        font-variant-numeric: tabular-nums;
      }

      .stack > span {
        opacity: 0;
      }

      .stack > span:first-child {
        opacity: 1;
      }

      .stack > span + span {
        position: absolute;
        top: 0;
        right: 0;
        white-space: nowrap;
      }

      .seats {
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 8px;
      }

      .seat {
        display: grid;
        justify-items: center;
        gap: 6px;
        font-size: 11px;
        color: color-mix(in srgb, var(--slate) 64%, transparent);
      }

      .avatar {
        position: relative;
        width: 32px;
        height: 32px;
        border-radius: 50%;
        display: grid;
        place-items: center;
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.02em;
        color: var(--slate);
        background: var(--ivory);
        border: 1.5px solid var(--oat);
      }

      /* The approved ring sits over the neutral one and fades in, so the
         border colour never repaints. */
      .avatar::after {
        content: '';
        position: absolute;
        inset: -1.5px;
        border-radius: 50%;
        border: 1.5px solid var(--olive);
        opacity: 0;
      }

      .tick {
        position: absolute;
        right: -4px;
        bottom: -3px;
        width: 14px;
        height: 14px;
        border-radius: 50%;
        background: var(--olive);
        border: 2px solid var(--paper);
        opacity: 0;
        transform: scale(0.9);
      }

      .tick svg {
        display: block;
        width: 100%;
        height: 100%;
      }

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

      /* Each reviewer owns a segment directly under their face. The bar is
         the sum, but nobody's share of it is anonymous. */
      .seg {
        position: relative;
        width: 100%;
        height: 6px;
        border-radius: 3px;
        background: var(--ivory);
        box-shadow: inset 0 0 0 1px var(--oat);
        overflow: hidden;
      }

      .seg i {
        position: absolute;
        inset: 0;
        border-radius: 3px;
        background: var(--olive);
        transform: scaleX(0);
        transform-origin: left;
      }

      .status {
        font-size: 12px;
        font-weight: 600;
      }

      .status > span + span {
        left: 0;
        right: auto;
      }

      /* Olive alone is ~3.4:1 on paper; pulled toward slate it passes as text. */
      .status .done {
        color: color-mix(in oklab, var(--olive), var(--slate) 35%);
      }

      /* Sam approves at 18%, Ana at 42%; the count and status step when each
         segment lands (25%, 49%), so every readout keeps the same record.
         Mo never does — and once quorum is met, Mo's seat steps back rather
         than vanishing: the empty segment stays on the record. */
      .sam .avatar::after,
      .sam .tick {
        animation: approve-a var(--_t) infinite;
      }

      .sam .seg i {
        animation: fill-a var(--_t) infinite;
      }

      .ana .avatar::after,
      .ana .tick {
        animation: approve-b var(--_t) infinite;
      }

      .ana .seg i {
        animation: fill-b var(--_t) infinite;
      }

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

      .count .c0 {
        animation: c0 var(--_t) steps(1, end) infinite;
      }

      .count .c1 {
        animation: c1 var(--_t) steps(1, end) infinite;
      }

      .count .c2 {
        animation: c2 var(--_t) steps(1, end) infinite;
      }

      .status .w2 {
        animation: c0 var(--_t) steps(1, end) infinite;
      }

      .status .w1 {
        animation: c1 var(--_t) steps(1, end) infinite;
      }

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

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

      /* 200ms pop from 0.9: the tick is stamped, not grown. */
      @keyframes approve-a {
        0%,
        18% {
          opacity: 0;
          transform: scale(0.9);
          animation-timing-function: var(--ease-out);
        }
        21%,
        95% {
          opacity: 1;
          transform: scale(1);
        }
        96%,
        100% {
          opacity: 0;
          transform: scale(0.9);
        }
      }

      @keyframes approve-b {
        0%,
        42% {
          opacity: 0;
          transform: scale(0.9);
          animation-timing-function: var(--ease-out);
        }
        45%,
        95% {
          opacity: 1;
          transform: scale(1);
        }
        96%,
        100% {
          opacity: 0;
          transform: scale(0.9);
        }
      }

      /* The segment follows its tick by 64ms and fills in 384ms of ease-out:
         the approval is the cause, the bar is the bookkeeping. */
      @keyframes fill-a {
        0%,
        19% {
          transform: scaleX(0);
          animation-timing-function: var(--ease-out);
        }
        25%,
        95% {
          transform: scaleX(1);
        }
        96%,
        100% {
          transform: scaleX(0);
        }
      }

      @keyframes fill-b {
        0%,
        43% {
          transform: scaleX(0);
          animation-timing-function: var(--ease-out);
        }
        49%,
        95% {
          transform: scaleX(1);
        }
        96%,
        100% {
          transform: scaleX(0);
        }
      }

      @keyframes excuse {
        0%,
        49% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        54%,
        95% {
          opacity: 0.55;
        }
        96%,
        100% {
          opacity: 1;
        }
      }

      @keyframes c0 {
        0%,
        25% {
          opacity: 1;
        }
        25.01%,
        95% {
          opacity: 0;
        }
        96%,
        100% {
          opacity: 1;
        }
      }

      @keyframes c1 {
        0%,
        25% {
          opacity: 0;
        }
        25.01%,
        49% {
          opacity: 1;
        }
        49.01%,
        100% {
          opacity: 0;
        }
      }

      @keyframes c2 {
        0%,
        49% {
          opacity: 0;
        }
        49.01%,
        95% {
          opacity: 1;
        }
        96%,
        100% {
          opacity: 0;
        }
      }

      /* "Ready" waits for the second segment to land: quorum is called on
         the record, not on the click. */
      @keyframes done {
        0%,
        49% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        52%,
        95% {
          opacity: 1;
        }
        96%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* No scale and no fill sweep: ticks and segments crossfade in place
           at twice the period, which still says who signed and in what order. */
        :root {
          --_t: calc(var(--t) * 2);
        }

        .tick,
        .seg i {
          transform: none !important;
        }

        .sam .seg i {
          animation-name: approve-a;
        }

        .ana .seg i {
          animation-name: approve-b;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="panel"
      role="img"
      aria-label="A review that needs two of three approvals. Each reviewer has their own segment of the bar under their avatar. Sam approves, then Ana; the count reaches two of two once Ana's segment lands and the status reads ready to merge, while Mo, who never reviewed, steps back with an empty segment still showing"
    >
      <div class="rig">
        <div class="head">
          <span>Approvals</span>
          <span class="stack count"
            ><span class="c0">0 of 2</span><span class="c1">1 of 2</span
            ><span class="c2">2 of 2</span></span
          >
        </div>
        <div class="seats">
          <div class="seat sam">
            <span class="avatar"
              >SA<span class="tick"
                ><svg viewBox="0 0 10 10" aria-hidden="true">
                  <path d="M2.6 5.2 4.3 6.8 7.4 3.4" /></svg></span
            ></span>
            <span>Sam</span>
            <span class="seg"><i></i></span>
          </div>
          <div class="seat ana">
            <span class="avatar"
              >AN<span class="tick"
                ><svg viewBox="0 0 10 10" aria-hidden="true">
                  <path d="M2.6 5.2 4.3 6.8 7.4 3.4" /></svg></span
            ></span>
            <span>Ana</span>
            <span class="seg"><i></i></span>
          </div>
          <div class="seat mo">
            <span class="avatar">MO</span>
            <span>Mo</span>
            <span class="seg"><i></i></span>
          </div>
        </div>
        <div class="status stack">
          <span class="w2">Needs 2 of Sam, Ana, Mo</span>
          <span class="w1">Waiting on Ana or Mo</span>
          <span class="done">Ready to merge</span>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

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