Sync — queue drain
Variant: Queue drain
Offline edits going up once the connection is back: four queued changes leave on a steady 1.02s beat, and each departure leaves its slot's oat outline behind, so remaining work stays countable against total capacity at a glance — the queue and the meter are the same object. The count beside it is a registered integer that ticks down only after its pip has fully cleared the row, because the number is the outcome of an upload rather than the intention; sequence it the other way and the counter claims work the wire has not finished. At zero the label trades for a drawn tick on the same quiet curve the pips left on. Reach for it in anything local-first — notes, drafts, field apps — where the user needs to watch a backlog shrink rather than take a spinner's word for it.
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 — queue drain</title>
<style>
@property --_n {
syntax: '<integer>';
inherits: true;
initial-value: 4;
}
: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) 62%, transparent);
/* Clock — 6.4s: four uploads on a steady 1.02s beat, a tick, a rest. The
chip resets inside a dark window at the wrap, so the pips are never
seen returning to their slots. */
--cycle: 6.4s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-accent: cubic-bezier(0.32, 0.72, 0, 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: inline-flex;
align-items: center;
gap: 12px;
padding: 11px 15px;
border-radius: calc(10px * var(--round));
background: var(--paper);
border: 1px solid var(--oat);
animation: card var(--t) linear infinite;
}
@keyframes card {
0% {
opacity: 0;
}
2.5% {
opacity: 1;
}
95% {
opacity: 1;
animation-timing-function: ease;
}
100% {
opacity: 0;
}
}
/* Each slot keeps its oat outline after its pip has gone: remaining work
stays countable against total capacity, so the row is the meter. */
.slots {
display: flex;
gap: 6px;
}
.slot {
position: relative;
width: 9px;
height: 9px;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px var(--oat);
}
.slot i {
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--clay);
}
/* Departures on a steady 1.02s cadence — uploads are a queue being
worked, not a burst — each one a 290ms rise that decelerates away. */
.slot:nth-child(1) i {
animation: pip1 var(--t) ease infinite;
}
.slot:nth-child(2) i {
animation: pip2 var(--t) ease infinite;
}
.slot:nth-child(3) i {
animation: pip3 var(--t) ease infinite;
}
.slot:nth-child(4) i {
animation: pip4 var(--t) ease infinite;
}
@keyframes pip1 {
0%,
12% {
opacity: 1;
transform: none;
animation-timing-function: var(--ease-out);
}
16.5%,
100% {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes pip2 {
0%,
28% {
opacity: 1;
transform: none;
animation-timing-function: var(--ease-out);
}
32.5%,
100% {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes pip3 {
0%,
44% {
opacity: 1;
transform: none;
animation-timing-function: var(--ease-out);
}
48.5%,
100% {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes pip4 {
0%,
60% {
opacity: 1;
transform: none;
animation-timing-function: var(--ease-out);
}
64.5%,
100% {
opacity: 0;
transform: translateY(-10px);
}
}
/* Two labels share one grid cell so the card never reflows. */
.track {
display: grid;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
}
.track > span {
grid-area: 1 / 1;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.q {
color: var(--_muted);
font-variant-numeric: tabular-nums;
animation:
count var(--t) linear infinite,
q-fade var(--t) ease infinite;
}
/* The count decrements only after its pip has fully cleared the row:
the number is the outcome of an upload, not the intention. */
.q b::before {
counter-reset: q var(--_n);
content: counter(q);
font-weight: 700;
color: var(--slate);
}
@keyframes count {
0%,
16.6% {
--_n: 4;
}
17%,
32.6% {
--_n: 3;
}
33%,
48.6% {
--_n: 2;
}
49%,
64.6% {
--_n: 1;
}
65%,
100% {
--_n: 0;
}
}
@keyframes q-fade {
0%,
66% {
opacity: 1;
transform: translateY(0);
}
68.5%,
100% {
opacity: 0;
transform: translateY(-4px);
}
}
.s {
font-weight: 600;
animation: s-fade var(--t) ease infinite;
}
@keyframes s-fade {
0%,
66.5% {
opacity: 0;
transform: translateY(4px);
animation-timing-function: var(--ease-out);
}
69.5%,
100% {
opacity: 1;
transform: translateY(0);
}
}
.s svg {
width: 12px;
height: 12px;
flex: none;
overflow: visible;
}
.s path {
fill: none;
stroke: var(--clay-d);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 15;
animation: s-draw var(--t) linear infinite;
}
@keyframes s-draw {
0%,
67% {
stroke-dashoffset: 15;
animation-timing-function: var(--ease-accent);
}
71%,
100% {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The queue still drains — the emptying slots are the meaning — but
each pip fades in place instead of launching, and the tick appears
already drawn. One shared 1.6x clock keeps the count in step. */
.card,
.slot i,
.q,
.s,
.s path {
animation-duration: calc(var(--t) * 1.6);
}
.slot:nth-child(1) i {
animation-name: pip1-rm;
transform: none;
}
.slot:nth-child(2) i {
animation-name: pip2-rm;
transform: none;
}
.slot:nth-child(3) i {
animation-name: pip3-rm;
transform: none;
}
.slot:nth-child(4) i {
animation-name: pip4-rm;
transform: none;
}
@keyframes pip1-rm {
0%,
12% {
opacity: 1;
}
16.5%,
100% {
opacity: 0;
}
}
@keyframes pip2-rm {
0%,
28% {
opacity: 1;
}
32.5%,
100% {
opacity: 0;
}
}
@keyframes pip3-rm {
0%,
44% {
opacity: 1;
}
48.5%,
100% {
opacity: 0;
}
}
@keyframes pip4-rm {
0%,
60% {
opacity: 1;
}
64.5%,
100% {
opacity: 0;
}
}
.q {
animation-name: count, q-rm;
}
@keyframes q-rm {
0%,
66% {
opacity: 1;
}
68.5%,
100% {
opacity: 0;
}
}
.s {
animation-name: s-rm;
transform: none;
}
@keyframes s-rm {
0%,
66.5% {
opacity: 0;
}
69.5%,
100% {
opacity: 1;
}
}
.s path {
animation: none;
stroke-dashoffset: 0;
}
}
</style>
</head>
<body>
<span
class="card"
role="img"
aria-label="A sync chip working through four queued changes: pips leave their slots one at a time, the count falls to zero, and a tick confirms the queue is synced"
>
<span class="slots">
<span class="slot"><i></i></span>
<span class="slot"><i></i></span>
<span class="slot"><i></i></span>
<span class="slot"><i></i></span>
</span>
<span class="track">
<span class="q"><b></b> queued</span>
<span class="s">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 8.5 L6.5 12 L13 4.5" />
</svg>
synced
</span>
</span>
</span>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.