A line chart drawn by a pen. The line draws itself, a dot rides the pen tip, the area under the line is wiped in right behind the tip, and each note lands as the pen reaches it. A dashed reference line (last period, a target) is wiped in first.
Use
<linkrel="stylesheet"href="tokens.css">
<linkrel="stylesheet"href="line/style.css">
<scriptsrc="play.js"></script>
<figureclass="line-chart" data-play style='--line-path: path("M0 246 C 9 243 ... 462 136")'>
<svgviewBox="0 0 640 260"role="img"aria-label="Describe the whole line in one sentence">
<pathclass="line-prev"d="..."/>
<pathclass="line-area"d="M0 246 C 9 243 ... 462 136 L462 250 L0 250 Z"/>
<pathclass="line-pen"pathLength="1"d="M0 246 C 9 243 ... 462 136"/>
<circleclass="line-dot"r="6"/>
</svg>
<pclass="line-note"data-x="320">Peak 5.3 kW</p>
</figure>
<scriptsrc="line/line.js"></script>
The pen, the dot's --line-path and the top edge of the area are the same path data. Put the dot's circle at 0,0: the path moves it. Load line.js at the end of the body.
Parts
Class
What it does
.line-pen
The line. Needs pathLength="1". Draws over --dur-draw (1600ms)
.line-dot
Rides the pen tip along --line-path with CSS offset-path
.line-area
The pen's path closed down to the baseline. Wiped in under the pen
.line-prev
Optional reference line, wiped in over --dur-slow before the pen
.line-note
A label placed over the chart. data-x is its x in the svg's own units: it lands when the pen gets there
--line-delay
On the chart. Holds the pen, so the reference line reads first (the demo uses 450ms)
Why the script
The pen draws at an even speed along its length, not along x. A steep stretch is long and narrow, so a plain left-to-right wipe falls behind the pen on the climbs and runs ahead on the flats. line.js measures the path and gives the area and the notes keyframes that follow the tip exactly, with the pen's own duration, delay and curve. Without it the area still wipes left to right in the same time and the notes land at the end.
Do
Write the line's story in the svg's aria-label: start, peak, the odd moment, where it ends.
Keep notes to two or three. They are the reading order.
Do not
Ride a dot on a line with many sharp corners: offset-path turns it through every one.
Give the area its own curve or duration. It only looks right glued to the pen.
Reduced motion
The line, the area, the dot and every note show in their final state, and line.js does nothing.
style.css
/*
* line: a line chart drawn by a pen. The line draws itself, a dot rides the pen tip on the same
* path, the area under the line is wiped in behind the pen, and notes land as the pen reaches them.
*
* The line and the dot are CSS. Arc length and x are not the same thing (a steep stretch is long and
* narrow), so line.js measures the path and gives the area wipe and the notes keyframes that follow
* the pen exactly. Without line.js the area wipes left to right in the same time and the notes land at
* the end: close, and still correct at rest.
*
* Markup: an svg with .line-pen (pathLength="1"), .line-area (the same path closed to the baseline)
* and .line-dot (a circle at 0,0). The dot follows --line-path, the pen's path data as path("...").
*/.line-chart { position: relative; }
.line-chart > svg { display: block; width: 100%; height: auto; overflow: visible; }
.line-pen { fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; }
.line-dot { offset-path: var(--line-path); offset-distance: 100%; offset-rotate: 0deg; }
.line-note { position: absolute; transform-origin: 50% 100%; }
@keyframes line-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes line-ride { from { offset-distance: 0%; } to { offset-distance: 100%; } }
@keyframes line-wipe { from { clip-path: inset(0100% 00); } to { clip-path: inset(0000); } }
@keyframes line-show { from { opacity: 0; } }
@keyframes line-land { from { opacity: 0; scale: .6; } }
/* Hidden start states only with play.js. --line-delay holds the pen (and everything that follows it). */.js .line-pen { stroke-dasharray: 1; stroke-dashoffset: 1; }
.js .line-dot { offset-distance: 0%; opacity: 0; }
.js .line-area { clip-path: inset(0100% 00); }
.js .line-prev { clip-path: inset(0100% 00); }
.js .line-note { opacity: 0; }
.js .go .line-pen, .js .go.line-chart .line-pen { animation: line-draw var(--dur-draw) var(--ease-inout) var(--line-delay, 0ms) both; }
.js .go .line-dot, .js .go.line-chart .line-dot { animation: line-ride var(--dur-draw) var(--ease-inout) var(--line-delay, 0ms) both, line-show var(--dur-quick) linear var(--line-delay, 0ms) both; opacity: 1; }
.js .go .line-area, .js .go.line-chart .line-area { animation: line-wipe var(--dur-draw) var(--ease-inout) var(--line-delay, 0ms) both; }
/* line-prev: a reference line (last period, a target) wiped in before the pen starts */.js .go .line-prev, .js .go.line-chart .line-prev { animation: line-wipe var(--dur-slow) var(--ease-inout) both; }
.js .go .line-note, .js .go.line-chart .line-note { animation: line-land var(--dur-base) var(--ease-spring) calc(var(--line-delay, 0ms) + var(--dur-draw)) both; opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.js .line-pen, .js .go .line-pen, .js .go.line-chart .line-pen { stroke-dashoffset: 0; animation: none; }
.js .line-dot, .js .go .line-dot, .js .go.line-chart .line-dot { offset-distance: 100%; opacity: 1; animation: none; }
.js :is(.line-area, .line-prev), .js .go :is(.line-area, .line-prev), .js .go.line-chart :is(.line-area, .line-prev) { clip-path: none; animation: none; }
.js .line-note, .js .go .line-note, .js .go.line-chart .line-note { opacity: 1; animation: none; }
}