Chart entrance — line drawn in time
Variant: Line drawn in time
A series traced left to right at a constant rate, because the x axis is time and an eased draw would claim the middle of the window happened faster than the ends of it. The stroke is drawn by dashoffset so its leading end stays a round cap rather than a vertical cut, while one clip edge on the same clock uncovers the fill and the points — separately timed reveals drift by a frame and the fill starts to look like it is leaking ahead of the line. Six readings, five straight segments, no smoothing: a spline would invent values between them that the series never contained.
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>Chart entrance — line drawn in time</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)
);
--_muted: color-mix(in srgb, var(--slate) 45%, transparent);
--_grid: color-mix(in srgb, var(--oat) 78%, transparent);
--_axis: color-mix(in srgb, var(--slate) 26%, transparent);
--_area: color-mix(in srgb, var(--clay) 15%, transparent);
/* Clock — 5.6s: a 224ms frame, a 980ms draw, then four seconds of the
chart just being a chart. The draw is the only thing being timed here. */
--cycle: 5.6s;
--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);
}
.chart {
width: min(288px, 88vw);
display: grid;
gap: 8px;
animation: frame var(--t) infinite;
}
.label {
font-size: clamp(11px, 3.2vw, 12px);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--_muted);
}
svg {
width: 100%;
height: auto;
display: block;
}
.grid {
stroke: var(--_grid);
stroke-width: 1;
}
.axis {
stroke: var(--_axis);
stroke-width: 1;
transform-origin: 0 0;
animation: rule var(--t) infinite;
}
.area {
fill: var(--_area);
}
/* Drawn along the path rather than clipped, so the front of the line is a
proper round end instead of a vertical cut. Linear, and deliberately so:
the x axis is time, and an eased draw would claim that the middle of the
window happened faster than the ends of it. */
.line {
fill: none;
stroke: var(--clay);
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 286;
animation: draw var(--t) infinite;
}
.dot {
fill: var(--clay-d);
}
/* One clipping edge uncovers the fill and the points, travelling on the
same clock as the stroke — so the three layers can never disagree about
where the front of the line is. Separately timed reveals drift by a
frame and the fill starts to look like it is leaking ahead. */
.wipe {
transform-origin: 0 0;
animation: wipe var(--t) infinite;
}
.tick {
fill: var(--_muted);
font-size: 12px;
font-family: inherit;
}
/* No curve smoothing: six readings joined by five straight segments. A
spline through these points would invent values between them that the
series never contained, which is a lie a chart entrance makes look
expensive. */
@keyframes draw {
0%,
5.5% {
stroke-dashoffset: 286;
animation-timing-function: linear;
}
23%,
93% {
stroke-dashoffset: 0;
}
94%,
100% {
stroke-dashoffset: 286;
}
}
@keyframes wipe {
0%,
5.5% {
transform: scaleX(0);
animation-timing-function: linear;
}
23%,
93% {
transform: scaleX(1);
}
94%,
100% {
transform: scaleX(0);
}
}
@keyframes rule {
0% {
transform: scaleX(0);
animation-timing-function: var(--ease-out);
}
6%,
100% {
transform: scaleX(1);
}
}
@keyframes frame {
0% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
4%,
88% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
92%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The draw survives — it is the series being read left to right, which
is the only claim this chart makes — but the loop stretches to 9.5s so
the front travels at a third of the pace. Everything scales by the
same 1.7, so the dash reset stays inside the dark window. */
.chart,
.line,
.wipe {
animation-duration: calc(var(--t) * 1.7);
}
.axis {
animation: none;
transform: scaleX(1);
}
}
</style>
</head>
<body>
<div
class="chart"
role="img"
aria-label="A line chart arriving: the axis draws first, then the series is traced left to right in time order with its area fill following the same edge"
>
<span class="label">p95 latency</span>
<svg viewBox="0 0 288 140" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="reveal">
<rect class="wipe" x="0" y="0" width="288" height="120" />
</clipPath>
</defs>
<line class="grid" x1="8" y1="20" x2="278" y2="20" />
<line class="grid" x1="8" y1="52" x2="278" y2="52" />
<line class="grid" x1="8" y1="84" x2="278" y2="84" />
<line class="axis" x1="0" y1="116" x2="288" y2="116" />
<g clip-path="url(#reveal)">
<path class="area" d="M8 96 L62 78 L116 86 L170 58 L224 46 L278 26 L278 116 L8 116 Z" />
<circle class="dot" cx="8" cy="96" r="3" />
<circle class="dot" cx="62" cy="78" r="3" />
<circle class="dot" cx="116" cy="86" r="3" />
<circle class="dot" cx="170" cy="58" r="3" />
<circle class="dot" cx="224" cy="46" r="3" />
<circle class="dot" cx="278" cy="26" r="3" />
</g>
<path class="line" d="M8 96 L62 78 L116 86 L170 58 L224 46 L278 26" />
<text class="tick" x="8" y="134" text-anchor="start">6h ago</text>
<text class="tick" x="143" y="134" text-anchor="middle">3h</text>
<text class="tick" x="278" y="134" text-anchor="end">now</text>
</svg>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.