Rollup — worst member wins
Variant: Worst member wins
A status header over five services that takes the worst state among them, and says whose state it took. The summary never reports "1 degraded": a count lets the failing member hide in the total, so the header reads "Queue degraded" and the queue row is tinted while it stays that way. Order carries the logic: the member turns first and the summary follows 180ms later, so whoever notices the header change finds its reason already lit below; recovery runs in the same order. Colour is a crossfade of stacked discs rather than a repaint, the label swap is 120ms out and 240ms in on ease-out with a 4px lift, and a single ring leaves the culprit at the moment it turns — it marks the change, not the state, so it does not keep pulsing while degraded. Use it for status pages, service health headers, and any badge that summarises children.
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>Rollup — worst member wins</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)
);
/* Clock — every duration in the document runs off --t. 1% is 60ms. */
--cycle: 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 {
width: min(244px, 86vw);
background: var(--paper);
border: 1px solid var(--oat);
border-radius: calc(10px * var(--round));
overflow: hidden;
}
.summary {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--oat);
}
/* Every status dot is two stacked discs, olive under clay, and a state
change is the clay one's opacity: a colour swap without repainting. */
.dot {
position: relative;
flex: none;
width: 8px;
height: 8px;
}
.dot i,
.dot b {
position: absolute;
inset: 0;
border-radius: 50%;
}
.dot i {
background: var(--olive);
}
.dot b {
background: var(--clay);
opacity: 0;
}
.summary .dot {
width: 10px;
height: 10px;
}
.label {
position: relative;
flex: 1;
height: 18px;
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.label span {
position: absolute;
inset: 0;
white-space: nowrap;
}
.label .bad {
opacity: 0;
color: var(--clay-d);
}
.members {
list-style: none;
padding: 4px 0;
}
.members li {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
height: 24px;
font-size: 12px;
color: color-mix(in srgb, var(--slate) 70%, transparent);
}
/* The culprit's row tint: what the summary's words point at. */
.members li::before {
content: '';
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--clay) 10%, transparent);
opacity: 0;
}
.members li > * {
position: relative;
}
/* Cause before effect. The member turns at 20%; the summary follows
180ms later at 23%, so the eye that catches the header change finds
its reason already lit below. Recovery runs the same order. */
.culprit .dot b,
.culprit::before {
animation: member var(--t) infinite;
}
.summary .bad {
animation: rollup var(--t) infinite;
}
.summary .dot b {
animation: rollup-dot var(--t) infinite;
}
.summary .ok {
animation: rollup-ok var(--t) infinite;
}
/* One ring off the culprit at the moment it turns, and only then: it
marks the change, not the state, so it does not repeat while degraded.
Sized at full spread and scaled up from the dot, so the stroke stays a
hairline instead of thickening as it grows. */
.culprit .ring {
position: absolute;
inset: -6.4px;
border-radius: 50%;
border: 1.5px solid var(--clay);
opacity: 0;
animation: ring var(--t) infinite;
}
@keyframes member {
0%,
20% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
23.5%,
66% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
70%,
100% {
opacity: 0;
}
}
@keyframes rollup {
0%,
23% {
opacity: 0;
transform: translateY(4px);
animation-timing-function: var(--ease-out);
}
27%,
69% {
opacity: 1;
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
71%,
100% {
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes rollup-dot {
0%,
23% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
27%,
69% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
72%,
100% {
opacity: 0;
}
}
@keyframes rollup-ok {
0%,
23% {
opacity: 1;
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
25%,
69% {
opacity: 0;
transform: translateY(-4px);
}
69.01% {
transform: translateY(4px);
animation-timing-function: var(--ease-out);
}
73%,
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes ring {
0%,
20% {
opacity: 0;
transform: scale(0.385);
}
20.01% {
opacity: 0.7;
transform: scale(0.385);
animation-timing-function: var(--ease-out);
}
32%,
100% {
opacity: 0;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
/* The ring and the label lift go; the crossfades stay, at twice the
period, because colour and words are the whole message. */
.culprit .ring {
display: none;
}
.culprit .dot b,
.culprit::before,
.summary .dot b,
.summary .bad,
.summary .ok {
animation-duration: calc(var(--t) * 2);
}
.summary .bad,
.summary .ok {
transform: none !important;
}
}
</style>
</head>
<body>
<div
class="panel"
role="img"
aria-label="A status summary over five services. When the queue degrades, its own row turns amber first and the summary follows a beat later, naming the queue instead of reporting an anonymous count; when it recovers, the summary returns to all systems normal"
>
<div class="summary">
<span class="dot"><i></i><b></b></span>
<span class="label">
<span class="ok">All systems normal</span>
<span class="bad">Queue degraded</span>
</span>
</div>
<ul class="members">
<li>
<span>API</span><span class="dot"><i></i><b></b></span>
</li>
<li>
<span>Web app</span><span class="dot"><i></i><b></b></span>
</li>
<li>
<span>Database</span><span class="dot"><i></i><b></b></span>
</li>
<li class="culprit">
<span>Queue</span><span class="dot"><i></i><b></b><span class="ring"></span></span>
</li>
<li>
<span>CDN</span><span class="dot"><i></i><b></b></span>
</li>
</ul>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.