Countdown — code window
Variant: Code window
A one-time code living out its validity window. The bar under it drains linearly — a timeout is a clock and a clock does not ease — and snaps to rust at one fifth remaining, a threshold rather than a gradient because expiring-soon is a rule, not a mood. The part that matters happens at zero: the dead code does not vanish, it holds stale in place at 35% opacity for 480ms, then fades out flat before its replacement rises in — never two half-codes superimposed — because the one thing an OTP screen must never do is change the number without saying so. The cycle runs two full windows so the loop seam is itself an expiry. Use it wherever a value carries a validity window — verification codes, signing links, held quotes — and the expiry has to be seen to be believed.
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 — code window</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 — 9.6s: two full validity windows, because the loop seam is
itself a code swap. One window would have to trade the second code
back for the first in plain sight; two means every change the
viewer ever sees is an expiry. */
--cycle: 9.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 {
display: grid;
gap: 12px;
justify-items: start;
padding: 18px 22px 20px;
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);
}
.code {
display: flex;
gap: 6px;
}
.cell {
display: grid;
place-items: center;
width: clamp(28px, 8vw, 34px);
height: clamp(38px, 11vw, 44px);
border: 1px solid var(--oat);
border-radius: calc(8px * var(--round));
background: var(--ivory);
font-size: clamp(17px, 5vw, 20px);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* A thin breath between the halves — codes are read in threes. */
.cell:nth-child(4) {
margin-left: 6px;
}
.cell span {
grid-area: 1 / 1;
}
.g-b {
opacity: 0;
}
.g-a {
animation: code-a var(--t) infinite;
}
.cell .g-b {
animation: code-b var(--t) infinite;
}
.window {
position: relative;
width: 100%;
height: 5px;
border-radius: 999px;
background: var(--oat);
overflow: hidden;
}
.fill {
position: absolute;
inset: 0;
border-radius: 999px;
transform-origin: left;
animation:
drain var(--t) infinite,
last-fifth var(--t) infinite;
}
/* The drain is linear because a validity window is a clock, and a clock
does not ease. The refill is the one eased move in the file — it
belongs to the new code arriving, not to time passing. */
@keyframes drain {
0% {
transform: scaleX(1);
animation-timing-function: linear;
}
40%,
45% {
transform: scaleX(0);
animation-timing-function: var(--ease-out);
}
47%,
50% {
transform: scaleX(1);
animation-timing-function: linear;
}
90%,
95% {
transform: scaleX(0);
animation-timing-function: var(--ease-out);
}
97%,
100% {
transform: scaleX(1);
}
}
/* Rust snaps in at one fifth remaining. A threshold is a rule, not a
gradient — the bar is either inside the window or nearly out of it,
and a slow blend olive-to-rust would claim some third state between. */
@keyframes last-fifth {
0%,
31.9% {
background: var(--olive);
}
32%,
44.9% {
background: var(--rust);
}
45%,
81.9% {
background: var(--olive);
}
82%,
94.9% {
background: var(--rust);
}
95%,
100% {
background: var(--olive);
}
}
/* Expiry is not disappearance. The dead code holds at 35% for 480ms,
still readable and visibly spent, because the one thing an OTP screen
must never do is change the number without saying so — that is how a
user types a code that stopped working mid-keystroke. It then fades
out flat and is gone before its replacement is visible: never two
half-codes superimposed, and only the incoming code travels. */
@keyframes code-a {
0%,
39.9% {
opacity: 1;
transform: translateY(0);
}
40%,
45% {
opacity: 0.35;
transform: translateY(0);
}
46.4% {
opacity: 0;
transform: translateY(0);
}
46.5%,
96.4% {
opacity: 0;
transform: translateY(3px);
}
96.5% {
opacity: 0;
transform: translateY(3px);
animation-timing-function: var(--ease-out);
}
98.5%,
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes code-b {
0%,
46.4% {
opacity: 0;
transform: translateY(3px);
}
46.5% {
opacity: 0;
transform: translateY(3px);
animation-timing-function: var(--ease-out);
}
48.5%,
89.9% {
opacity: 1;
transform: translateY(0);
}
90%,
95% {
opacity: 0.35;
transform: translateY(0);
}
96.4% {
opacity: 0;
transform: translateY(0);
}
96.5%,
100% {
opacity: 0;
transform: translateY(3px);
}
}
@media (prefers-reduced-motion: reduce) {
/* The window and the swaps stay — a code with no clock under it is
the lie this piece exists to avoid — but the whole thing runs at
two-thirds pace on the one shared clock, so the stale beat still
lands exactly when the bar empties. */
.fill,
.g-a,
.cell .g-b {
animation-duration: calc(var(--t) * 1.5);
}
}
</style>
</head>
<body>
<div
class="panel"
role="img"
aria-label="A six-digit one-time code above a linear validity bar; the bar drains, snaps to rust for its last fifth, the expired code holds stale in place for a beat, then a fresh code rises in and the window refills"
>
<span class="label">One-time code</span>
<span class="code">
<span class="cell"><span class="g-a">4</span><span class="g-b">1</span></span>
<span class="cell"><span class="g-a">8</span><span class="g-b">3</span></span>
<span class="cell"><span class="g-a">2</span><span class="g-b">5</span></span>
<span class="cell"><span class="g-a">9</span><span class="g-b">6</span></span>
<span class="cell"><span class="g-a">0</span><span class="g-b">6</span></span>
<span class="cell"><span class="g-a">7</span><span class="g-b">4</span></span>
</span>
<span class="window"><span class="fill"></span></span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.