Progress bar — reported in jumps
Variant: Reported in jumps
A determinate bar fed the way progress actually arrives: five reports at irregular gaps, each landing as a 319ms ease-out move with dead stillness in between. The stillness is the argument — a bar that trickles between reports has made motion the target instead of the measure, and once the movement itself is the message it has stopped reporting anything. One pair of registered numbers on one set of keyframes drives the fill and the percentage, so they cannot disagree on any frame, and the stall at 79 runs over a second because that is what uploads do — an honest hold is what makes the landing believable.
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>Progress bar — reported in jumps</title>
<style>
/* One state, two renderings: a smooth number places the fill and an
integer on the same keyframes renders the readout, so the bar and the
percentage cannot disagree on any frame. */
@property --_p {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@property --_n {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
: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;
--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) 52%, transparent);
--_track: color-mix(in srgb, var(--oat) 55%, transparent);
/* Clock — 5.8s: five reports at irregular gaps, each a 319ms move,
a 1.07s stall at 79, the landing, and a settled tail. */
--cycle: 5.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 {
width: min(300px, 82vw);
display: grid;
gap: 10px;
animation:
report var(--t) infinite,
life var(--t) ease infinite;
}
/* The state machine. Every move gets the same 5.5% window and the same
deceleration, because every report is an arrival; the value between
moves is a hold, not a slower move — nothing was reported, so nothing
happens. The stall at 79 is the longest hold on purpose. */
@keyframes report {
0%,
6% {
--_p: 0;
--_n: 0;
animation-timing-function: var(--ease-out);
}
11.5%,
17% {
--_p: 14;
--_n: 14;
animation-timing-function: var(--ease-out);
}
22.5%,
32% {
--_p: 37;
--_n: 37;
animation-timing-function: var(--ease-out);
}
37.5%,
45% {
--_p: 58;
--_n: 58;
animation-timing-function: var(--ease-out);
}
50.5%,
69% {
--_p: 79;
--_n: 79;
animation-timing-function: var(--ease-out);
}
74.5%,
100% {
--_p: 100;
--_n: 100;
}
}
/* Arrive, dim once the work is done — finished means no longer live —
and reset under a dark beat at the tail. */
@keyframes life {
0%,
1% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
4%,
77% {
opacity: 1;
}
81%,
91% {
opacity: 0.6;
}
94.5%,
100% {
opacity: 0;
}
}
.row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.lbl {
display: grid;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
}
.lbl span {
grid-area: 1 / 1;
}
.uploading {
animation: uploading var(--t) ease infinite;
}
@keyframes uploading {
0%,
75.5% {
opacity: 1;
}
78.5%,
100% {
opacity: 0;
}
}
.uploaded {
color: var(--_muted);
animation: uploaded var(--t) ease infinite;
}
@keyframes uploaded {
0%,
77% {
opacity: 0;
}
80%,
100% {
opacity: 1;
}
}
.pct {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--_muted);
min-width: 4ch;
text-align: right;
counter-reset: p var(--_n);
}
.pct::after {
content: counter(p) '%';
}
.rail {
position: relative;
height: 6px;
border-radius: 999px;
background: var(--_track);
overflow: hidden;
}
/* The fill is a full-width pill parked off the left edge and translated
by the number — transform only, and the leading cap stays round. */
.fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--clay);
transform: translateX(calc((var(--_p) - 100) * 1%));
}
@media (prefers-reduced-motion: reduce) {
/* The value still climbs — a progress bar that never progresses is
reporting nothing — but each report lands as a step instead of a
move, and the clock stretches so the holds read as holds. */
.card {
animation-name: report-rm, life-rm;
animation-duration: calc(var(--t) * 1.6), calc(var(--t) * 1.6);
animation-timing-function: steps(1, end), ease;
}
.uploading,
.uploaded {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes report-rm {
0%,
11.4% {
--_p: 0;
--_n: 0;
}
11.5%,
22.4% {
--_p: 14;
--_n: 14;
}
22.5%,
37.4% {
--_p: 37;
--_n: 37;
}
37.5%,
50.4% {
--_p: 58;
--_n: 58;
}
50.5%,
74.4% {
--_p: 79;
--_n: 79;
}
74.5%,
100% {
--_p: 100;
--_n: 100;
}
}
@keyframes life-rm {
0%,
1% {
opacity: 0;
}
4%,
91% {
opacity: 1;
}
94.5%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="card"
role="img"
aria-label="An upload progress bar fed by five reports at irregular gaps: the fill eases to each reported value, holds dead still between reports, stalls just short of the end, then lands on 100%"
>
<div class="row">
<span class="lbl">
<span class="uploading">Uploading</span>
<span class="uploaded">Uploaded</span>
</span>
<span class="pct"></span>
</div>
<div class="rail">
<div class="fill"></div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.