Sync — offline backoff
Variant: Offline backoff
The user-facing half of the story this library's retry diagrams tell as infrastructure: a connection pill drops to rust and a countdown rail under it drains toward the next attempt — linear, because a scheduled retry is a clock and easing it would misreport the wait. The first attempt fails, and the argument lands in what happens next: the rail re-arms and drains again at exactly half the rate. Showing the doubled wait beats a pill that just says "reconnecting…", because the honest answer to "is it stuck?" is a visible schedule. The failure itself gets no shake and no apology — rust simply returns, since the rail already says what happens next — and the reconnect gets the file's one deceleration, because coming back is the only moment here that answers the person watching.
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>Sync — offline backoff</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)
);
--_muted: color-mix(in srgb, var(--slate) 62%, transparent);
/* Clock — 8s: the connection drops at 5%, a 0.96s wait, a failed
attempt, a 1.92s wait — the same rail draining at half the rate —
then the attempt that lands. Opens and closes connected, so the
loop needs no reset. */
--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);
}
.wrap {
display: grid;
gap: 7px;
animation: wrap var(--t) ease infinite;
}
/* Quiet while connected, full weight the moment it is not: the pill is
chrome, and chrome only earns attention when something is wrong. */
@keyframes wrap {
0%,
5% {
opacity: 0.72;
}
6.5%,
66% {
opacity: 1;
}
72%,
100% {
opacity: 0.72;
}
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 13px 7px 11px;
border-radius: 999px;
background: var(--paper);
border: 1px solid var(--oat);
}
.glyph {
width: 12px;
height: 12px;
flex: none;
position: relative;
}
.glyph > * {
position: absolute;
inset: 0;
}
.d-olive,
.d-rust {
margin: 2px;
border-radius: 50%;
}
.d-olive {
background: var(--olive);
animation: olive var(--t) ease infinite;
}
/* The reconnect gets the file's one deceleration: coming back is the
moment that answers the person watching. */
@keyframes olive {
0%,
5% {
opacity: 1;
transform: scale(1);
}
6.2%,
58.5% {
opacity: 0;
transform: scale(0.9);
animation-timing-function: var(--ease-out);
}
61.5%,
100% {
opacity: 1;
transform: scale(1);
}
}
/* The drop is sharp and the failure is silent: rust just returns, with
no shake and no apology — the rail already says what happens next. */
.d-rust {
background: var(--rust);
opacity: 0;
animation: rust var(--t) ease infinite;
}
@keyframes rust {
0%,
5% {
opacity: 0;
}
5.9%,
19% {
opacity: 1;
}
20%,
26% {
opacity: 0;
}
27.2%,
52% {
opacity: 1;
}
53.2%,
100% {
opacity: 0;
}
}
.d-arc {
width: 12px;
height: 12px;
overflow: visible;
}
.d-arc circle {
fill: none;
stroke: var(--clay);
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 20 8.3;
transform-origin: 6px 6px;
animation:
arc-spin var(--t) linear infinite,
arc-fade var(--t) ease infinite;
}
@keyframes arc-spin {
0%,
19.5% {
transform: rotate(-90deg);
}
26% {
transform: rotate(630deg);
}
52.3% {
transform: rotate(630deg);
}
58.6%,
100% {
transform: rotate(1350deg);
}
}
@keyframes arc-fade {
0%,
19.5% {
opacity: 0;
}
20.3%,
25.3% {
opacity: 1;
}
26.2%,
52.3% {
opacity: 0;
}
53.1%,
58% {
opacity: 1;
}
58.8%,
100% {
opacity: 0;
}
}
/* Three labels share one grid cell; the pill never reflows. */
.lbl {
display: grid;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
}
.lbl span {
grid-area: 1 / 1;
white-space: nowrap;
}
.connected {
animation: connected var(--t) ease infinite;
}
@keyframes connected {
0%,
5% {
opacity: 1;
}
/* In step with the olive dot's 58.5–61.5% ramp, so the label cell is
never empty at the one moment someone is actually watching it. */
6.2%,
58.8% {
opacity: 0;
}
61.5%,
100% {
opacity: 1;
}
}
.offline {
color: var(--rust);
opacity: 0;
animation: offline var(--t) ease infinite;
}
@keyframes offline {
0%,
5% {
opacity: 0;
}
5.9%,
19% {
opacity: 1;
}
20%,
26% {
opacity: 0;
}
27.2%,
52% {
opacity: 1;
}
53.2%,
100% {
opacity: 0;
}
}
.retrying {
color: var(--_muted);
opacity: 0;
animation: retrying var(--t) ease infinite;
}
@keyframes retrying {
0%,
19.7% {
opacity: 0;
}
20.5%,
25.5% {
opacity: 1;
}
26.4%,
52.5% {
opacity: 0;
}
53.3%,
58% {
opacity: 1;
}
58.9%,
100% {
opacity: 0;
}
}
/* The countdown to the next attempt. Linear, because a scheduled retry
is a clock and easing it would misreport the wait. The second drain
covers the same rail in twice the time — the backoff, visible. */
.rail {
height: 2px;
border-radius: 1px;
background: color-mix(in srgb, var(--oat) 55%, transparent);
overflow: hidden;
}
.rail i {
display: block;
height: 100%;
border-radius: 1px;
background: var(--rust);
transform-origin: left center;
animation:
rail-drain var(--t) linear infinite,
rail-fade var(--t) ease infinite;
}
@keyframes rail-drain {
0%,
7% {
transform: scaleX(1);
}
19%,
26.7% {
transform: scaleX(0);
}
/* Re-armed while hidden: a new timer, not the old one refilling. */
27%,
28% {
transform: scaleX(1);
}
52%,
100% {
transform: scaleX(0);
}
}
@keyframes rail-fade {
0%,
6% {
opacity: 0;
}
7%,
25.8% {
opacity: 1;
}
26.4%,
27.2% {
opacity: 0;
}
28%,
52.5% {
opacity: 1;
}
54%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The two countdown drains stay — they are the datum, and the piece
without them says nothing — while the dots stop scaling and the
spinner becomes a slow blink. One shared 1.6x clock. */
.wrap,
.d-olive,
.d-rust,
.d-arc circle,
.connected,
.offline,
.retrying,
.rail i {
animation-duration: calc(var(--t) * 1.6);
}
.d-olive {
animation-name: olive-rm;
transform: none;
}
@keyframes olive-rm {
0%,
5% {
opacity: 1;
}
6.2%,
58.5% {
opacity: 0;
}
61.5%,
100% {
opacity: 1;
}
}
.d-arc circle {
animation-name: arc-rm;
transform: rotate(-90deg);
}
@keyframes arc-rm {
0%,
19.5% {
opacity: 0;
}
21%,
22.8% {
opacity: 1;
}
23.9% {
opacity: 0.5;
}
25%,
25.6% {
opacity: 1;
}
26.2%,
52.3% {
opacity: 0;
}
54%,
55.5% {
opacity: 1;
}
56.6% {
opacity: 0.5;
}
57.6%,
58.2% {
opacity: 1;
}
58.8%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="wrap"
role="img"
aria-label="A connection pill drops offline, a countdown rail drains to a retry that fails, drains again at half the rate, and the second attempt reconnects"
>
<span class="pill">
<span class="glyph">
<i class="d-olive"></i>
<i class="d-rust"></i>
<svg class="d-arc" viewBox="0 0 12 12" aria-hidden="true">
<circle cx="6" cy="6" r="4.5" />
</svg>
</span>
<span class="lbl">
<span class="connected">Connected</span>
<span class="offline">Offline</span>
<span class="retrying">Retrying</span>
</span>
</span>
<span class="rail"><i></i></span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.