Back to the library

Countdown — sliding expiry

Variant: Sliding expiry

A sliding expiration in three acts: an idle timeout drains at one fixed slope, a keystroke lands with 40% remaining and snaps it back to full in 160ms, a second does the same, and the third never comes. The asymmetry is the piece — the drain holds one linear slope through all three acts because activity resets a timeout rather than slowing one down, while the refill decelerates because it is the system answering a person. The extension level is a mark printed on the track, so when the fill crosses it with nobody coming it turns rust: below the line help has always arrived by, this time is different, and the bar says so before zero does. The label flips on the frame the bar empties, never a moment sooner. Reach for it on session-timeout warnings and idle-lock indicators — anywhere staying signed in is something the user’s own activity keeps earning.

statuscontrolloop

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 — sliding expiry</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 — 8s. A 2.4s idle window drained at one fixed rate through
           all three acts: two keystrokes arrive with 40% remaining and reset
           it, the third never comes. Same slope everywhere, because activity
           resets a timeout — it does not slow one down. */
        --cycle: 8s;
        --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);
      }

      .card {
        display: grid;
        gap: 13px;
        width: min(260px, 80vw);
        padding: 18px 20px 20px;
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
      }

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

      .title {
        font-size: 13px;
        font-weight: 600;
        letter-spacing: -0.01em;
      }

      /* Both states share one grid cell so neither reflows the header when
         they trade places. */
      .state {
        display: grid;
        font-size: 11.5px;
        font-weight: 600;
      }

      .state span {
        grid-area: 1 / 1;
      }

      .live {
        color: color-mix(in srgb, var(--olive) 82%, var(--slate));
        animation: live-out var(--t) ease infinite;
      }

      .dead {
        color: var(--rust);
        opacity: 0;
        animation: dead-in var(--t) ease infinite;
      }

      .row {
        display: flex;
        align-items: center;
        gap: 12px;
      }

      .key {
        flex: none;
        display: grid;
        place-items: center;
        width: 26px;
        height: 26px;
        border: 1px solid var(--oat);
        border-bottom-width: 2.5px;
        border-radius: calc(7px * var(--round));
        background: var(--ivory);
        font-size: 12px;
        color: var(--_muted);
        animation:
          key-tap var(--t) infinite,
          key-gone var(--t) ease infinite;
      }

      .rail {
        position: relative;
        flex: 1;
        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:
          slide var(--t) infinite,
          past-help var(--t) infinite;
      }

      /* The level every extension has arrived by, printed on the track. In
         the third act the fill crosses it and keeps going — the mark is what
         turns "draining" into "draining with nobody coming". */
      .mark {
        position: absolute;
        top: -3px;
        bottom: -3px;
        left: 40%;
        width: 2px;
        border-radius: 999px;
        background: color-mix(in srgb, var(--slate) 26%, transparent);
      }

      /* Three drains, one slope: 60% of the bar in 18% of the cycle, 100% in
         30%. The refills decelerate because an extension is the system
         answering a person — 160ms for a keystroke reset, 240ms for the full
         re-arm, the same fill rate — while the drain never eases, because it
         is a clock. */
      @keyframes slide {
        0% {
          transform: scaleX(1);
          animation-timing-function: linear;
        }
        18% {
          transform: scaleX(0.4);
          animation-timing-function: var(--ease-out);
        }
        20% {
          transform: scaleX(1);
          animation-timing-function: linear;
        }
        38% {
          transform: scaleX(0.4);
          animation-timing-function: var(--ease-out);
        }
        40% {
          transform: scaleX(1);
          animation-timing-function: linear;
        }
        70%,
        85% {
          transform: scaleX(0);
          animation-timing-function: var(--ease-out);
        }
        88%,
        100% {
          transform: scaleX(1);
        }
      }

      /* Rust arrives exactly where the fill passes the extension mark with no
         keystroke in sight — below the level help has always arrived by, this
         time is different, and the bar says so before zero does. */
      @keyframes past-help {
        0%,
        57.9% {
          background: var(--olive);
        }
        58%,
        84.9% {
          background: var(--rust);
        }
        85%,
        100% {
          background: var(--olive);
        }
      }

      /* The press leads the refill by a few frames — cause, then effect. The
         dip is 40ms down with a short beat at the bottom, and the release
         decelerates: the cheapest possible statement that a hand did this. */
      @keyframes key-tap {
        0%,
        17.1% {
          transform: translateY(0);
        }
        17.6%,
        17.9% {
          transform: translateY(1.5px);
          animation-timing-function: var(--ease-out);
        }
        19.4%,
        37.1% {
          transform: translateY(0);
        }
        37.6%,
        37.9% {
          transform: translateY(1.5px);
          animation-timing-function: var(--ease-out);
        }
        39.4%,
        100% {
          transform: translateY(0);
        }
      }

      /* The key dims only when the session dies — it is the person, and the
         piece never claims they left until the timeout proves it. */
      @keyframes key-gone {
        0%,
        69.9% {
          opacity: 1;
        }
        70%,
        84.9% {
          opacity: 0.35;
        }
        88%,
        100% {
          opacity: 1;
        }
      }

      /* The state flips on the frame the bar empties, not a moment before:
         a session is live until the instant it is not, and a label that
         faded alongside the drain would claim a half-signed-in state no
         session store models. */
      @keyframes live-out {
        0%,
        69.9% {
          opacity: 1;
        }
        70%,
        84.9% {
          opacity: 0;
        }
        88%,
        100% {
          opacity: 1;
        }
      }

      @keyframes dead-in {
        0%,
        69.9% {
          opacity: 0;
        }
        70%,
        84.9% {
          opacity: 1;
        }
        88%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The drain and the resets are the story and stay; the key keeps its
           dim but loses its travel. One multiplier on the shared clock keeps
           every reset landing on the frame its keystroke does. */
        .fill,
        .key,
        .live,
        .dead {
          animation-duration: calc(var(--t) * 1.5);
        }

        .key {
          animation-name: key-gone;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="card"
      role="img"
      aria-label="A session idle bar draining at a fixed rate; two keystrokes land with 40% remaining and snap it back to full, the third never comes, the fill turns rust as it crosses the extension mark, and the session flips to signed out on the frame the bar empties"
    >
      <div class="head">
        <span class="title">Session</span>
        <span class="state">
          <span class="live">Active</span>
          <span class="dead">Signed out</span>
        </span>
      </div>
      <div class="row">
        <span class="key">&#9166;</span>
        <span class="rail">
          <span class="track"><span class="fill"></span></span>
          <span class="mark"></span>
        </span>
      </div>
    </div>
  </body>
</html>

The variant family

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