Back to the library

Countdown — stepped seconds

Variant: Stepped seconds

A countdown that refuses to glide: the digit lands once a second with nothing between the landings, because a countdown reports seconds and a tween between two of them claims time flowed at whatever rate the curve picked. Every change in the file is a steps(1) cut — the loop seam back to five is the same cut as every tick, so the reset has no rewind to show. The final three digits arrive pre-coloured in rust, escalation the eye reads from across the room, and five pips spend one per tick into empty rings rather than vanishing: the spent seconds keep their seats, so the time left is always measured against how much there was. Reach for it on launch clocks, drop timers, and going-live counters — anywhere the second itself is the content and smoothness would be a decoration in front of it.

statuscounterloop

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 — stepped seconds</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: five one-second ticks, then zero holds for 1.4s. One
           second of animation is one second of countdown; the demo does not
           get to run time at a flattering rate. */
        --cycle: 6.4s;
        --t: calc(var(--cycle) / var(--speed));
      }

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

      .tile {
        display: grid;
        gap: 10px;
        justify-items: center;
        padding: 20px 30px 22px;
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
      }

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

      .digit {
        width: 1.2ch;
        height: 1.08em;
        overflow: hidden;
        font-size: clamp(56px, 17vw, 76px);
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        line-height: 1;
      }

      .strip {
        display: grid;
        animation: tick var(--t) steps(1, end) infinite;
      }

      .strip i {
        height: 1.08em;
        line-height: 1.08em;
        font-style: normal;
        text-align: center;
      }

      /* Escalation is carried by the glyphs themselves: the strip only ever
         brings pre-coloured digits into the window, so the colour can never
         change on any frame the digit is not also changing. */
      .strip i:nth-child(3),
      .strip i:nth-child(4),
      .strip i:nth-child(5) {
        color: var(--rust);
      }

      .strip i:nth-child(6) {
        color: var(--clay-d);
      }

      /* No easing anywhere: a countdown reports seconds, and a tween between
         two seconds claims time flowed at whatever rate the curve picked.
         steps(1, end) makes every landing a literal cut, and the loop seam
         back to five is the same cut, so the reset has no rewind to show. */
      @keyframes tick {
        0%,
        15.5% {
          transform: translateY(0);
        }
        15.625%,
        31.125% {
          transform: translateY(-16.6667%);
        }
        31.25%,
        46.75% {
          transform: translateY(-33.3333%);
        }
        46.875%,
        62.375% {
          transform: translateY(-50%);
        }
        62.5%,
        78% {
          transform: translateY(-66.6667%);
        }
        78.125%,
        100% {
          transform: translateY(-83.3333%);
        }
      }

      .pips {
        display: flex;
        gap: 9px;
        padding-top: 2px;
      }

      /* The seconds that are gone keep their seats. A pip spends to an empty
         ring instead of disappearing, so how much time there was to begin
         with never stops being visible — urgency is read against the full
         row, not against a shrinking one. */
      .pip {
        position: relative;
        width: 7px;
        height: 7px;
        border-radius: 50%;
        box-shadow: inset 0 0 0 1.5px var(--oat);
      }

      .pip::after {
        content: '';
        position: absolute;
        inset: 0;
        border-radius: 50%;
        background: var(--slate);
        animation-duration: var(--t);
        animation-timing-function: steps(1, end);
        animation-iteration-count: infinite;
      }

      .pip:nth-child(1)::after {
        animation-name: spend-1;
      }
      .pip:nth-child(2)::after {
        animation-name: spend-2;
      }
      .pip:nth-child(3)::after {
        animation-name: spend-3;
      }
      .pip:nth-child(4)::after {
        animation-name: spend-4;
      }
      .pip:nth-child(5)::after {
        animation-name: spend-5;
      }

      @keyframes spend-1 {
        0%,
        15.5% {
          opacity: 1;
        }
        15.625%,
        100% {
          opacity: 0;
        }
      }

      @keyframes spend-2 {
        0%,
        31.125% {
          opacity: 1;
        }
        31.25%,
        100% {
          opacity: 0;
        }
      }

      @keyframes spend-3 {
        0%,
        46.75% {
          opacity: 1;
        }
        46.875%,
        100% {
          opacity: 0;
        }
      }

      @keyframes spend-4 {
        0%,
        62.375% {
          opacity: 1;
        }
        62.5%,
        100% {
          opacity: 0;
        }
      }

      @keyframes spend-5 {
        0%,
        78% {
          opacity: 1;
        }
        78.125%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The countdown is the datum — a frozen digit says nothing — and its
           changes are already cuts rather than travel, so reduced motion only
           slows the clock. Everything scales by the same 1.6, which is what
           keeps each pip spending on the frame its digit lands. */
        .strip,
        .pip::after {
          animation-duration: calc(var(--t) * 1.6);
        }
      }
    </style>
  </head>
  <body>
    <div
      class="tile"
      role="img"
      aria-label="A launch countdown stepping from five to zero once a second with nothing between the landings, the final three seconds in rust, while five pips spend one per tick and remain as empty rings"
    >
      <span class="label">T-minus</span>
      <span class="digit">
        <span class="strip"><i>5</i><i>4</i><i>3</i><i>2</i><i>1</i><i>0</i></span>
      </span>
      <span class="pips">
        <span class="pip"></span>
        <span class="pip"></span>
        <span class="pip"></span>
        <span class="pip"></span>
        <span class="pip"></span>
      </span>
    </div>
  </body>
</html>

The variant family

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