Back to the library
Tideline sync, swell
Variant: Swell (ease-in-out)
A sync progress bar that fills in four surges. Each one pushes further, pulls back a little and leaves a pale mark at the highest point it reached, then the bar settles on 'Up to date'. Use it for background sync or long uploads where the work arrives in batches and a steady bar would suggest a precision you don't have. This is the reference curve: the strong ease-in-out slows right down at the top and bottom of each surge, so the bar reads as moving in batches instead of stuttering.
loaderprogresssyncambientease-in-out
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>Tideline sync — Swell</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
(#d97757 / #b85c3e / #788c5d / #b04a3f). */
--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. */
--cycle: 4.8s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
color: var(--slate);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
.card {
width: min(360px, 88vw);
padding: clamp(16px, 5vw, 22px);
display: grid;
gap: clamp(12px, 3.5vw, 16px);
background: var(--paper);
border: 1px solid var(--oat);
border-radius: calc(16px * var(--round));
}
.head { display: flex; align-items: center; gap: 12px; }
.icon {
width: 36px;
height: 36px;
flex: none;
display: grid;
place-items: center;
background: var(--ivory);
border: 1px solid var(--oat);
border-radius: calc(10px * var(--round));
}
.icon svg { width: 20px; height: 20px; fill: none; stroke: var(--slate); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.title { margin: 0; font-size: clamp(14px, 4vw, 15px); font-weight: 600; }
.meta { margin: 2px 0 0; font-size: 12px; opacity: 0.62; }
.status { display: grid; font-size: 13px; }
.status > span { grid-area: 1 / 1; display: flex; align-items: center; gap: 6px; }
.is-syncing { animation: syncing var(--t) var(--ease-out) infinite; }
.is-synced { opacity: 0; animation: synced var(--t) var(--ease-out) infinite; }
.check { width: 14px; height: 14px; fill: none; }
.check path {
stroke: var(--olive);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw var(--t) var(--ease-out) infinite;
}
.track {
position: relative;
height: 10px;
background: var(--oat);
border-radius: 999px;
overflow: hidden;
clip-path: inset(0 round 999px);
}
.pool { position: absolute; inset: 0; animation: ebb var(--t) var(--ease-out) infinite; }
/* Each layer is a full-track sheet slid by translateX, so the pill leading
edge never distorts. The extra 24% on the left keeps the trailing edge
covered if a curve overshoots past full. */
.reach { position: absolute; inset: 0; transform: translateX(-100%); }
.reach::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: -24%;
right: 0;
border-radius: 999px;
}
.mark::before { background: var(--clay); opacity: 0.3; }
.water::before { background: linear-gradient(90deg, var(--clay-d), var(--clay)); }
/* Surges take 14% of the loop and each pull-back takes 8%: water runs up the
beach slowly and drains back fast. */
.mark { animation: highwater var(--t) var(--ease-in-out) infinite; }
.water { animation: tide var(--t) var(--ease-in-out) infinite; }
@keyframes tide {
0% { transform: translateX(-100%); }
14% { transform: translateX(-68%); }
22% { transform: translateX(-76%); }
36% { transform: translateX(-42%); }
44% { transform: translateX(-50%); }
58% { transform: translateX(-16%); }
66% { transform: translateX(-22%); }
78%, 100% { transform: translateX(0); }
}
@keyframes highwater {
0% { transform: translateX(-100%); }
14%, 22% { transform: translateX(-68%); }
36%, 44% { transform: translateX(-42%); }
58%, 66% { transform: translateX(-16%); }
78%, 100% { transform: translateX(0); }
}
@keyframes ebb {
0%, 90% { opacity: 1; }
96%, 100% { opacity: 0; }
}
@keyframes syncing {
0%, 76% { opacity: 1; transform: none; }
79% { opacity: 0; transform: translateY(-4px); }
95% { opacity: 0; transform: translateY(4px); }
99%, 100% { opacity: 1; transform: none; }
}
@keyframes synced {
0%, 78% { opacity: 0; transform: translateY(4px); }
82%, 91% { opacity: 1; transform: none; }
94%, 100% { opacity: 0; transform: translateY(-4px); }
}
@keyframes draw {
0%, 79% { stroke-dashoffset: 1; }
86%, 100% { stroke-dashoffset: 0; }
}
@keyframes breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
@media (max-width: 380px) {
.card { padding: 16px; }
.icon { width: 32px; height: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.pool, .mark, .is-syncing, .is-synced, .check path { animation: none; }
.mark, .is-synced { opacity: 0; }
.is-syncing { opacity: 1; }
.water {
transform: translateX(-38%);
animation: breathe calc(var(--t) * 2) var(--ease-in-out) infinite;
}
}
</style>
</head>
<body>
<div class="card" role="status" aria-live="polite">
<div class="head">
<div class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 16l5-5 4 4 3-3 6 6" /><circle cx="16" cy="9" r="1.5" /></svg>
</div>
<div>
<p class="title">Photo library</p>
<p class="meta">1,284 items</p>
</div>
</div>
<div class="status">
<span class="is-syncing">Syncing changes…</span>
<span class="is-synced" aria-hidden="true"><svg class="check" viewBox="0 0 16 16"><path d="M3 8.5l3 3 7-7" pathLength="1" /></svg>Up to date</span>
</div>
<div class="track" aria-hidden="true">
<div class="pool">
<div class="reach mark"></div>
<div class="reach water"></div>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.