Back to the library
Job complete, warm
Variant: Warm
The same completion given room: the mark becomes a 54px disc with a single halo rising off it, and three lines of copy stagger in behind over about 700ms. One halo, not three — the restraint is the point, since this is a consumer moment seen a few times a week rather than a milestone. Use it when the job was something the user asked for and waited on.
successcontrolsvgloop
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>Job complete — warm</title>
<style>
:root {
--ivory: #faf9f5;
--slate: #141413;
--clay: #d97757;
--oat: #e3dacc;
--cycle: 5.6s;
--out: cubic-bezier(0.16, 0.84, 0.3, 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);
}
.done {
/* Grows with the stage instead of sitting at 200px in a 1100px frame.
Floors at the original width, so the two mobile sizes are unchanged. */
width: clamp(200px, 34vw, 340px);
display: grid;
justify-items: center;
text-align: center;
gap: 14px;
animation: card var(--cycle) linear infinite;
}
@keyframes card {
0% {
opacity: 0;
}
3% {
opacity: 1;
}
95% {
opacity: 1;
animation-timing-function: ease;
}
100% {
opacity: 0;
}
}
.mark {
position: relative;
/* Same ramp as the card, so the mark holds its 0.27 proportion. */
width: clamp(54px, 9.2vw, 92px);
height: clamp(54px, 9.2vw, 92px);
display: grid;
place-items: center;
}
/* One halo, once. Two would be a performance. */
.mark .halo {
position: absolute;
inset: 0;
border-radius: 50%;
border: clamp(1.5px, 0.25vw, 2.5px) solid var(--clay);
animation: halo var(--cycle) linear infinite;
}
@keyframes halo {
0%,
9% {
transform: scale(1);
opacity: 0;
animation-timing-function: var(--out);
}
10% {
opacity: 0.45;
}
19%,
100% {
transform: scale(1.9);
opacity: 0;
}
}
.mark .disc {
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--clay);
animation: disc var(--cycle) linear infinite;
}
@keyframes disc {
0%,
6% {
transform: scale(0.92);
opacity: 0;
animation-timing-function: var(--out);
}
13%,
100% {
transform: scale(1);
opacity: 1;
}
}
.mark svg {
position: relative;
width: clamp(26px, 4.4vw, 44px);
height: clamp(26px, 4.4vw, 44px);
overflow: visible;
}
.mark path {
fill: none;
stroke: var(--ivory);
stroke-width: 3.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 36;
animation: draw var(--cycle) linear infinite;
}
@keyframes draw {
0%,
10.5% {
stroke-dashoffset: 36;
animation-timing-function: var(--out);
}
17%,
100% {
stroke-dashoffset: 0;
}
}
.copy {
display: grid;
gap: 5px;
}
.title {
font-size: clamp(15px, 2.45vw, 24px);
font-weight: 600;
letter-spacing: -0.015em;
animation: rise var(--cycle) linear infinite;
}
.sub {
font-size: clamp(12px, 1.85vw, 18px);
color: color-mix(in srgb, var(--slate) 55%, transparent);
font-variant-numeric: tabular-nums;
animation: rise-2 var(--cycle) linear infinite;
}
/* A three-beat stagger, ~170ms apart. Written as explicit offsets rather
than animation-delay so nothing is caught mid-pose on the first cycle. */
@keyframes rise {
0%,
12% {
opacity: 0;
transform: translateY(6px);
animation-timing-function: var(--out);
}
19%,
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes rise-2 {
0%,
15% {
opacity: 0;
transform: translateY(6px);
animation-timing-function: var(--out);
}
22%,
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes rise-3 {
0%,
18% {
opacity: 0;
transform: translateY(6px);
animation-timing-function: var(--out);
}
25%,
100% {
opacity: 1;
transform: translateY(0);
}
}
.chip {
font-size: clamp(11px, 1.8vw, 17px);
letter-spacing: 0.01em;
color: color-mix(in srgb, var(--slate) 62%, transparent);
background: var(--oat);
border-radius: 999px;
/* em so the pill keeps its shape as the label scales. */
padding: 0.36em 0.9em;
animation: rise-3 var(--cycle) linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.mark .halo {
animation: halo-rm var(--cycle) linear infinite;
transform: scale(1.35);
}
@keyframes halo-rm {
0%,
9% {
opacity: 0;
}
14% {
opacity: 0.3;
}
24%,
100% {
opacity: 0;
}
}
.mark .disc {
animation: fade-rm var(--cycle) linear infinite;
transform: scale(1);
}
.title,
.sub,
.chip {
animation: fade-rm var(--cycle) linear infinite;
transform: none;
}
.sub {
animation-name: fade-rm-2;
}
.chip {
animation-name: fade-rm-3;
}
@keyframes fade-rm {
0%,
8% {
opacity: 0;
}
16%,
100% {
opacity: 1;
}
}
@keyframes fade-rm-2 {
0%,
11% {
opacity: 0;
}
19%,
100% {
opacity: 1;
}
}
@keyframes fade-rm-3 {
0%,
14% {
opacity: 0;
}
22%,
100% {
opacity: 1;
}
}
.mark path {
animation: fade-rm var(--cycle) linear infinite;
stroke-dashoffset: 0;
}
}
</style>
</head>
<body>
<div class="done" role="status" aria-label="Your export is ready">
<div class="mark">
<span class="halo"></span>
<span class="disc"></span>
<svg viewBox="0 0 40 40" aria-hidden="true">
<path d="M9 20.5 L17 28.5 L31.5 12" />
</svg>
</div>
<div class="copy">
<div class="title">Your export is ready</div>
<div class="sub">1,204 rows · 3m 14s</div>
</div>
<span class="chip">accounts-2026.csv</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.