Line draw — draw and erase
Variant: Draw and erase
The loading outline, and the only member of the family with no fade, no dark window and no reset anywhere in it. One dash as long as the whole perimeter is what lets a single monotonically falling offset do both halves of the job: down to zero the outline draws itself around the card, and past zero the same dash slides off the far side so it erases from the corner it started at. The last frame is the first frame — there is no seam to hide, which is why nothing here has to dim. Linear throughout, with the rhythm living in two holds instead: 360ms on the closed outline and a 216ms rest with nothing drawn, because an indicator that never pauses reads as a machine rather than a process. Ease the travel instead and the outline slows into every corner it passes, which is a claim about the shape that nothing in the shape supports.
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>Line draw — draw and erase</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)
);
/* Clock — 3.6s: 1.51s of head, a 360ms hold on the closed outline,
1.51s of tail, and a 216ms rest with nothing drawn. No fade anywhere
in the file: the erase ends exactly where the draw began, so there is
no reset to hide. */
--cycle: 3.6s;
--t: calc(var(--cycle) / var(--speed));
--_calm: calc(2.4s / var(--speed));
--ease-accent: cubic-bezier(0.45, 0, 0.55, 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 {
width: min(280px, 88vw);
height: auto;
display: block;
}
.field {
fill: var(--oat);
}
.plate {
fill: var(--paper);
stroke: var(--oat);
stroke-width: 1.5;
}
/* One dash the length of the whole perimeter is what lets a single
monotonically falling offset do both halves of the job: from +103 to 0
the dash advances over the path and the outline draws, and from 0 to
-103 it slides off the far side and the outline erases from the corner
it started at. The gap is twice the perimeter and the travel overshoots
by three units, both so that the dash's round cap is parked off the
path — at an offset of exactly 100 it still paints, and a stroke that
is meant to be absent shows as a speck in the top left corner. */
.trace {
fill: none;
stroke: var(--clay);
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 100 200;
animation: trace var(--t) infinite;
}
/* Linear throughout. The two holds are where the rhythm lives — ease the
travel instead and the outline slows into every corner it passes, which
is a claim about the shape that nothing in the shape supports. */
@keyframes trace {
0% {
stroke-dashoffset: 103;
animation-timing-function: linear;
}
42%,
52% {
stroke-dashoffset: 0;
animation-timing-function: linear;
}
94%,
100% {
stroke-dashoffset: -103;
}
}
@media (prefers-reduced-motion: reduce) {
/* Travel is the thing being removed, so the outline stays closed and
breathes instead. Between 0.3 and 1 over 2.4s: slow enough not to
read as a blink, and still obviously running. */
.trace {
stroke-dashoffset: 0;
animation: trace-calm var(--_calm) var(--ease-accent) infinite;
}
@keyframes trace-calm {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 1;
}
}
}
</style>
</head>
<body>
<svg
class="card"
viewBox="0 0 260 140"
role="img"
aria-label="An outline drawing itself around a card and then erasing from the same corner it started, on a continuous loop"
>
<path
class="plate"
d="M 24 10 H 236 A 14 14 0 0 1 250 24 V 116 A 14 14 0 0 1 236 130 H 24 A 14 14 0 0 1 10 116 V 24 A 14 14 0 0 1 24 10 Z"
/>
<circle class="field" cx="44" cy="42" r="12" />
<rect class="field" x="66" y="34" width="118" height="9" rx="4.5" />
<rect class="field" x="66" y="51" width="76" height="9" rx="4.5" />
<rect class="field" x="32" y="82" width="196" height="9" rx="4.5" />
<rect class="field" x="32" y="99" width="140" height="9" rx="4.5" />
<path
class="trace"
pathLength="100"
d="M 24 10 H 236 A 14 14 0 0 1 250 24 V 116 A 14 14 0 0 1 236 130 H 24 A 14 14 0 0 1 10 116 V 24 A 14 14 0 0 1 24 10 Z"
/>
</svg>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.