Back to the library

Countdown — threshold drain

Variant: Threshold drain

The zoned drain: a window emptying at one linear rate past two marks printed on the track at one half and one fifth. The fill snaps olive to clay to rust on the frames it crosses them — blending would claim states between the zones that the rules never defined — and the marks stay standing after the level has passed, so how far gone things are is always read against the line itself. A status dot holds still through two zones and gains a 512ms pulse only in the last: cadence carries the escalation to peripheral vision, where colour alone cannot. The 640ms hold at empty is deliberate — zero is a state, not a frame. Use it for rate limits, quota windows, and any meter governed by published thresholds, where the question is not how much is left but which rule applies right now.

statusprogresserrorloop

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>Countdown — threshold drain</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)
        );

        --_muted: color-mix(in srgb, var(--slate) 45%, transparent);

        /* Clock — 6.4s: a 5.12s drain, a 640ms empty hold, a 128ms refill.
           The hold exists because zero is a state, not a frame — a bar that
           refills the instant it empties never actually ran out. */
        --cycle: 6.4s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --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 {
        display: grid;
        gap: 12px;
        width: min(250px, 78vw);
        padding: 18px 20px 20px;
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
      }

      .head {
        display: flex;
        align-items: center;
        justify-content: space-between;
      }

      .label {
        font-size: 11.5px;
        font-weight: 600;
        letter-spacing: 0.14em;
        text-transform: uppercase;
        color: var(--_muted);
      }

      .dot {
        width: 8px;
        height: 8px;
        border-radius: 50%;
        animation:
          zones var(--t) infinite,
          urgency var(--t) var(--ease-in-out) infinite;
      }

      .rail {
        position: relative;
        height: 6px;
      }

      .track {
        position: absolute;
        inset: 0;
        border-radius: 999px;
        background: var(--oat);
        overflow: hidden;
      }

      .fill {
        position: absolute;
        inset: 0;
        border-radius: 999px;
        transform-origin: left;
        animation:
          drain var(--t) infinite,
          zones var(--t) infinite;
      }

      /* The thresholds are printed on the track, not the fill: the rules stay
         standing after the level has passed them, so how far past the line
         things are is always read against the line itself. */
      .mark {
        position: absolute;
        top: -3px;
        bottom: -3px;
        width: 2px;
        border-radius: 999px;
        background: color-mix(in srgb, var(--slate) 26%, transparent);
      }

      .mark-half {
        left: 50%;
      }

      .mark-fifth {
        left: 20%;
      }

      /* Linear, because time remaining is a clock and a clock does not ease.
         The refill decelerates — it is the system resetting the window, an
         event, not time running backwards. */
      @keyframes drain {
        0% {
          transform: scaleX(1);
          animation-timing-function: linear;
        }
        80%,
        90% {
          transform: scaleX(0);
          animation-timing-function: var(--ease-out);
        }
        92%,
        100% {
          transform: scaleX(1);
        }
      }

      /* Colour snaps on the frame the level crosses each mark — 40% and 64%
         of the cycle are where a linear drain reaches one half and one fifth.
         A blend would claim states between the zones that the rules do not
         define. */
      @keyframes zones {
        0%,
        39.9% {
          background: var(--olive);
        }
        40%,
        63.9% {
          background: var(--clay);
        }
        64%,
        89.9% {
          background: var(--rust);
        }
        90%,
        100% {
          background: var(--olive);
        }
      }

      /* The dot holds still through two zones and gains a pulse only in the
         last — cadence is the escalation the colour alone cannot carry to
         someone who is not looking straight at it. 512ms a beat, three beats,
         then still for the last 128ms of the zone: it stops when there is
         nothing left to escalate, a breath before the reset. */
      @keyframes urgency {
        0%,
        63.9% {
          transform: scale(1);
        }
        68% {
          transform: scale(1.35);
        }
        72% {
          transform: scale(1);
        }
        76% {
          transform: scale(1.35);
        }
        80% {
          transform: scale(1);
        }
        84% {
          transform: scale(1.35);
        }
        88%,
        100% {
          transform: scale(1);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The drain is the datum and stays; the dot keeps its colour and
           loses its travel. One shared multiplier keeps the snaps landing
           exactly where the level crosses the marks. */
        .fill,
        .dot {
          animation-duration: calc(var(--t) * 1.6);
        }

        .dot {
          animation-name: zones;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="card"
      role="img"
      aria-label="A rate-limit window draining linearly along a track with threshold marks at one half and one fifth; the fill snaps olive to clay to rust as it crosses them, a status dot pulses only in the final zone, and the window resets after a beat at empty"
    >
      <div class="head">
        <span class="label">Window resets</span>
        <span class="dot"></span>
      </div>
      <div class="rail">
        <span class="track"><span class="fill"></span></span>
        <span class="mark mark-half"></span>
        <span class="mark mark-fifth"></span>
      </div>
    </div>
  </body>
</html>

The variant family

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