Motion patterns Get motion.css

Patterns

Line and area

A line chart drawn by a pen: the line draws itself, a dot rides the tip, the area is wiped in right under it, and notes land as the pen passes them.

Open the demo alone
0.00s

Timeline loads with the demo.

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

<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="line/style.css">
<script src="play.js"></script>

<figure class="line-chart" data-play style='--line-path: path("M0 246 C 9 243 ... 462 136")'>
  <svg viewBox="0 0 640 260" role="img" aria-label="Describe the whole line in one sentence">
    <path class="line-prev" d="..."/>
    <path class="line-area" d="M0 246 C 9 243 ... 462 136 L462 250 L0 250 Z"/>
    <path class="line-pen" pathLength="1" d="M0 246 C 9 243 ... 462 136"/>
    <circle class="line-dot" r="6"/>
  </svg>
  <p class="line-note" data-x="320">Peak 5.3 kW</p>
</figure>

<script src="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

ClassWhat it does
.line-penThe line. Needs pathLength="1". Draws over --dur-draw (1600ms)
.line-dotRides the pen tip along --line-path with CSS offset-path
.line-areaThe pen's path closed down to the baseline. Wiped in under the pen
.line-prevOptional reference line, wiped in over --dur-slow before the pen
.line-noteA label placed over the chart. data-x is its x in the svg's own units: it lands when the pen gets there
--line-delayOn 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(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@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(0 100% 0 0); }
.js .line-prev { clip-path: inset(0 100% 0 0); }
.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; }
}