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.
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">⏎</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.