Line draw — route traced
Variant: Route traced
Progress along a path that was already settled: the planned route is drawn in full before anything moves, so the only thing being reported is how much of it is behind you. That is the difference between this and a line inventing itself, and it is why the piece suits delivery tracking, onboarding maps and pipeline stages. One registered number drives the travelled stroke’s dashoffset and all five stops’ opacity, so a stop cannot light early — there is no second timeline to drift against, and each threshold is simply the fraction of the route’s arc length lying behind it. Linear start to finish at 82 units a second, and the stops crossfade linearly too, because a stop is not responding to anyone; it is reporting where the front of the line is, and easing the report puts it out of step with the thing reported on.
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 — route traced</title>
<style>
/* One registered number is the entire clock. It drives the travelled
stroke's dashoffset and every stop's opacity, so a stop physically
cannot light before the line reaches it — there is no second timeline
to drift against. */
@property --_p {
syntax: '<number>';
initial-value: 0;
inherits: true;
}
: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) 42%, transparent);
/* Each stop's number below is the fraction of the route's arc length
that lies behind it, times 100, minus the 3.2 units the front takes
to cross the stop itself. Nothing here was tuned by eye — move a
corner and these are recomputed, not renegotiated. */
--_s1: 16.4;
--_s2: 37.7;
--_s3: 57.4;
--_s4: 78.7;
--_s5: 96.8;
--_cross: 0.3125; /* 1 / 3.2 — the ramp is one stop-width wide */
/* Clock — 5.4s: a 3.24s traverse of 265 units, then a hold on the
completed route. 82 units a second, constant, start to finish. */
--cycle: 5.4s;
--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);
}
.route {
width: min(280px, 88vw);
height: auto;
display: block;
animation: advance var(--t) infinite;
}
/* The planned route is drawn in full before anything moves. That is the
difference between this and a line inventing itself: the shape is known,
and the only thing being reported is how much of it is behind you. */
.planned {
fill: none;
stroke: var(--oat);
stroke-width: 3.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.travelled {
fill: none;
stroke: var(--clay);
stroke-width: 3.5;
stroke-linecap: round;
stroke-linejoin: round;
/* Gap 200, not 100: with a gap equal to the path the pattern wraps at
an offset of exactly 100 and the round cap paints a dot on the far
end of a route nobody has started travelling. The offset itself is
left alone at 100 minus the progress, because every stop's number
below is an arc-length fraction and a lead-in would put the two out
of step by the width of the lead. */
stroke-dasharray: 100 200;
stroke-dashoffset: calc((100 - var(--_p)) * 1px);
}
.stop {
fill: var(--ivory);
stroke: var(--oat);
stroke-width: 3;
}
.origin {
fill: var(--clay-d);
}
/* Linear, and deliberately: a stop is not responding to the user, it is
reporting where the front of the line is. Easing it would put the
report slightly out of step with the thing it reports on. */
.reached {
fill: var(--clay);
opacity: clamp(0, calc((var(--_p) - var(--_on)) * var(--_cross)), 1);
}
.r1 {
--_on: var(--_s1);
}
.r2 {
--_on: var(--_s2);
}
.r3 {
--_on: var(--_s3);
}
.r4 {
--_on: var(--_s4);
}
.r5 {
--_on: var(--_s5);
}
.progress {
animation: progress-hold var(--t) infinite;
}
.label {
fill: var(--_muted);
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
}
@keyframes advance {
0%,
6% {
--_p: 0;
animation-timing-function: linear;
}
66%,
96% {
--_p: 100;
}
100% {
--_p: 0;
}
}
/* Progress is dark by 96%, so the retraction of --_p over the last 4% is
never seen. The planned route and the stops underneath do not fade with
it — the map is not what resets. */
@keyframes progress-hold {
0%,
90% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
96%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* 2x, which drops the front to 41 units a second. The traverse is the
only claim this makes, so it stays; there is nothing else to remove. */
.route,
.progress {
animation-duration: calc(var(--t) * 2);
}
}
</style>
</head>
<body>
<svg
class="route"
viewBox="0 0 280 152"
role="img"
aria-label="A route drawing itself along a planned path through five stops, each stop filling with colour exactly as the line reaches it"
>
<path class="planned" pathLength="100" d="M 24 116 H 76 L 116 76 H 168 L 208 36 H 256" />
<circle class="stop" cx="76" cy="116" r="6" />
<circle class="stop" cx="116" cy="76" r="6" />
<circle class="stop" cx="168" cy="76" r="6" />
<circle class="stop" cx="208" cy="36" r="6" />
<circle class="stop" cx="256" cy="36" r="7.5" />
<circle class="stop origin" cx="24" cy="116" r="6" />
<g class="progress">
<path class="travelled" pathLength="100" d="M 24 116 H 76 L 116 76 H 168 L 208 36 H 256" />
<circle class="reached r1" cx="76" cy="116" r="4" />
<circle class="reached r2" cx="116" cy="76" r="4" />
<circle class="reached r3" cx="168" cy="76" r="4" />
<circle class="reached r4" cx="208" cy="36" r="4" />
<circle class="reached r5" cx="256" cy="36" r="5.5" />
</g>
<text class="label" x="24" y="140" text-anchor="start">DEPART</text>
<text class="label" x="256" y="18" text-anchor="end">ARRIVE</text>
</svg>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.