Progress bar — staged pipeline
Variant: Staged pipeline
A pipeline drawn as a plan and reported against: four segments sized to each stage's expected share of the run, so proportion is a promise made before anything moves. Within a stage the fill is linear — machine work answering to no one — and each stage begins on the frame the one before it lands; the gaps between segments are joints in the plan, never crossed and never counted. The labels carry the state channel: pending sits faint, the running stage holds clay, done settles toward slate, each switch landing on the frame the fill crosses a joint. Reach for it when which stage matters more than what percent — builds, deploys, imports.
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 — staged pipeline</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;
--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)
);
--_pending: color-mix(in srgb, var(--slate) 38%, transparent);
--_done: color-mix(in srgb, var(--slate) 66%, transparent);
--_track: color-mix(in srgb, var(--oat) 55%, transparent);
/* Clock — 6.4s: four stages of unequal length back to back, a settled
beat, and a reset under a fade. */
--cycle: 6.4s;
--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: life var(--t) ease infinite;
}
@keyframes life {
0%,
1% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
4%,
70% {
opacity: 1;
}
74%,
91% {
opacity: 0.6;
}
94.5%,
100% {
opacity: 0;
}
}
/* Segment widths are the plan: each stage's share of the rail is its
expected share of the wall clock, so the rail promises proportions
before any of them runs and the fill is a report against that promise. */
.rail,
.names {
display: flex;
gap: 4px;
}
.seg {
height: 6px;
border-radius: 999px;
background: var(--_track);
overflow: hidden;
position: relative;
}
.seg::after {
content: '';
position: absolute;
inset: 0;
background: var(--clay);
transform-origin: left center;
transform: scaleX(0);
}
.s1 {
width: 18%;
}
.s2 {
width: 34%;
}
.s3 {
width: 30%;
}
.s4 {
width: 18%;
}
/* Within a stage the fill runs linear — machine work answering to no
one — and each stage begins on the frame the one before it lands:
the gaps are joints in the plan, never crossed, never counted. */
.s1::after {
animation: f1 var(--t) linear infinite;
}
.s2::after {
animation: f2 var(--t) linear infinite;
}
.s3::after {
animation: f3 var(--t) linear infinite;
}
.s4::after {
animation: f4 var(--t) linear infinite;
}
@keyframes f1 {
0%,
4% {
transform: scaleX(0);
}
15.2%,
100% {
transform: scaleX(1);
}
}
@keyframes f2 {
0%,
15.2% {
transform: scaleX(0);
}
36.2%,
100% {
transform: scaleX(1);
}
}
@keyframes f3 {
0%,
36.2% {
transform: scaleX(0);
}
54.8%,
100% {
transform: scaleX(1);
}
}
@keyframes f4 {
0%,
54.8% {
transform: scaleX(0);
}
66%,
100% {
transform: scaleX(1);
}
}
.names {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
}
.names span {
overflow: hidden;
white-space: nowrap;
}
/* Colour is the state channel: pending faint, running clay, done settled
slate. The label lights on the frame its segment starts filling. */
.n1 {
width: 18%;
animation: c1 var(--t) ease infinite;
}
.n2 {
width: 34%;
animation: c2 var(--t) ease infinite;
}
.n3 {
width: 30%;
animation: c3 var(--t) ease infinite;
}
.n4 {
width: 18%;
animation: c4 var(--t) ease infinite;
}
@keyframes c1 {
0%,
3.5% {
color: var(--_pending);
}
4.5%,
14.7% {
color: var(--clay-d);
}
16.7%,
100% {
color: var(--_done);
}
}
@keyframes c2 {
0%,
14.7% {
color: var(--_pending);
}
15.7%,
35.7% {
color: var(--clay-d);
}
37.7%,
100% {
color: var(--_done);
}
}
@keyframes c3 {
0%,
35.7% {
color: var(--_pending);
}
36.7%,
54.3% {
color: var(--clay-d);
}
56.3%,
100% {
color: var(--_done);
}
}
@keyframes c4 {
0%,
54.3% {
color: var(--_pending);
}
55.3%,
65.5% {
color: var(--clay-d);
}
67.5%,
100% {
color: var(--_done);
}
}
@media (prefers-reduced-motion: reduce) {
/* The wipes go; the sequence stays. Each segment appears filled on the
frame its stage completes, and the labels keep trading colour —
the pipeline still tells its story, one state at a time. */
.s1::after,
.s2::after,
.s3::after,
.s4::after {
animation-timing-function: steps(1, end);
animation-duration: calc(var(--t) * 1.6);
}
.card,
.n1,
.n2,
.n3,
.n4 {
animation-duration: calc(var(--t) * 1.6);
}
}
</style>
</head>
<body>
<div
class="card"
role="img"
aria-label="A four-stage pipeline progress bar: Fetch, Build, Test and Deploy segments sized to their expected share of the run, each filling in turn while its label holds clay"
>
<div class="rail">
<span class="seg s1"></span>
<span class="seg s2"></span>
<span class="seg s3"></span>
<span class="seg s4"></span>
</div>
<div class="names">
<span class="n1">Fetch</span>
<span class="n2">Build</span>
<span class="n3">Test</span>
<span class="n4">Deploy</span>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.