How a stroke draws itself
A stroke can be cut into dashes. stroke-dasharray sets how long each dash and each gap is, and stroke-dashoffset slides that pattern along the line. Make one dash as long as the whole line, push it back by its own length, and nothing shows. Animate the offset to 0 and the dash slides into place: the line appears to draw from its start to its end.
The only number this needs is the line’s length, and pathLength="1" removes it. It tells the browser to treat the shape as 1 unit long for dash purposes, whatever its real length, so the dash is 1 and the offset runs from 1 to 0 for every shape you draw.
<svg class="draw" viewBox="0 0 120 80" width="240" height="160" aria-hidden="true">
<path pathLength="1" d="M10 70 L60 10 L110 70 Z" />
</svg>.draw > * {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linejoin: round;
/* One dash as long as the line, then a gap twice as long. */
stroke-dasharray: 1 2;
/* The dash pushed back by its whole length: nothing shows. */
stroke-dashoffset: 1;
animation: draw 1.5s ease-out forwards;
}
@keyframes draw {
to { stroke-dashoffset: 0; }
}Why a gap of 2 and not 1? With stroke-dasharray: 1 the next dash starts exactly where the line ends, and WebKit draws a dot at that corner of a closed, round-capped shape before anything should show. A gap twice the line puts the next dash out of reach in every engine. The SVG line drawing generator writes it that way for the same reason; its page lists this and the other cases measured in all three engines.
// No pathLength: measure each shape and use its real length.
for (const shape of document.querySelectorAll('.draw > *')) {
const length = shape.getTotalLength();
shape.style.strokeDasharray = `${length} ${length * 2}`;
shape.style.strokeDashoffset = length;
}Several paths, one after another
A real drawing is many shapes. pathLength works on all of them (path, line, polyline, polygon, rect, circle and ellipse), so one rule draws them all. Give each shape its place in the order as a custom property, and let animation-delay multiply it.
<svg class="draw" viewBox="0 0 200 100" width="400" height="200" aria-hidden="true">
<rect pathLength="1" style="--i: 0" x="10" y="40" width="80" height="50" />
<path pathLength="1" style="--i: 1" d="M10 40 L50 10 L90 40" />
<circle pathLength="1" style="--i: 2" cx="150" cy="35" r="20" />
<line pathLength="1" style="--i: 3" x1="100" y1="90" x2="190" y2="90" />
</svg>.draw > * {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-dasharray: 1 2;
stroke-dashoffset: 1;
animation: draw 1s ease-out forwards;
/* Each shape starts 0.6s after the one before it. */
animation-delay: calc(var(--i) * 0.6s);
}The @keyframes draw rule is the one from above. Each shape draws from where its outline starts: a path from its first point, a rect from its top-left corner going clockwise, a circle from its rightmost point (3 o’clock) going clockwise. To change where a path starts, reverse it: the SVG path editor has a button for that, and the SVG path viewer shows every path’s length and commands, and what would stop it drawing.
Make it loop
For one shape, a loop is two words: infinite alternate. It draws, then undraws back to its start, then draws again.
.loop > * {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-dasharray: 1 2;
/* Draw, then undraw, for ever. */
animation: draw-loop 2s ease-in-out infinite alternate;
}
@keyframes draw-loop {
from { stroke-dashoffset: 1; }
to { stroke-dashoffset: 0; }
}Several shapes need more than that. With one shared keyframe and a different delay on each, every shape repeats on its own schedule, so the drawing is never whole and never empty at the same moment. Put every shape on the same cycle instead, each with its own keyframes for its slice of it:
/* One 4s cycle for both lines: the first draws, then the second,
both hold, and both clear together before it starts again. */
.loop-many > * {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-dasharray: 1 2;
animation: 4s ease-in-out infinite;
}
.loop-many > :nth-child(1) { animation-name: loop-1; }
.loop-many > :nth-child(2) { animation-name: loop-2; }
@keyframes loop-1 {
0% { stroke-dashoffset: 1; }
30%, 80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 1; }
}
@keyframes loop-2 {
0%, 30% { stroke-dashoffset: 1; }
60%, 80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 1; }
}That gets long by hand for more than a few shapes. The generator writes the same per-shape keyframes for any drawing you paste in, as a repeat or an alternate loop.
Respect reduced motion
A line drawing is decoration. For readers who ask their system for less motion, show it finished. The safest way is to put the dash and the animation inside @media (prefers-reduced-motion: no-preference): everyone else gets a plain stroke with no dash at all, so there is no hidden state to undo.
/* Everyone gets the finished drawing. */
.draw > * {
fill: none;
stroke: currentColor;
stroke-width: 3;
}
/* Only readers who have not asked for less motion see it draw. */
@media (prefers-reduced-motion: no-preference) {
.draw > * {
stroke-dasharray: 1 2;
stroke-dashoffset: 1;
animation: draw 1s ease-out forwards;
animation-delay: calc(var(--i) * 0.6s);
}
}The other way round, @media (prefers-reduced-motion: reduce) with animation: none, also has to set the offset back to 0, and loses to any more specific rule that sets an animation-name, like the :nth-child rules in the loop above. Why decoration should finish rather than vanish is in reduced motion for scroll animations.
Draw it on scroll
The same keyframe can follow the scroll instead of the clock. Give the SVG a named view timeline, which runs from the moment it starts to enter the screen to the moment it has left, and point each shape’s animation at it. animation-range gives each shape its own slice of that crossing, which is the scroll version of a stagger.
.draw > * {
fill: none;
stroke: currentColor;
stroke-width: 3;
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
/* The SVG crossing the screen is the timeline every shape shares. */
.draw { view-timeline: --drawing; }
.draw > * {
stroke-dasharray: 1 2;
stroke-dashoffset: 1;
animation: draw linear both;
animation-timeline: --drawing;
/* Shape i draws over its own slice of the crossing. */
animation-range:
cover calc(10% + var(--i) * 10%)
cover calc(40% + var(--i) * 10%);
}
}
}The timeline is set on the <svg> and not on each shape, so every shape measures the same box. The animation shorthand resets animation-timeline and animation-range, so both come after it. Browsers without scroll timelines skip the whole @supports block and show the drawing finished. How view() and the range names work, and which browsers have them, is in CSS scroll-driven animations explained.
CSS on a scroll timeline has limits: it plays backwards when the reader scrolls back, it cannot stop once the drawing is done, and it fires no event when it finishes. svg-scroll-draw covers those. Since 2.23.4 it reads a pathLength on a shape, so the markup above works as it is:
import { scrollDraw } from 'svg-scroll-draw';
// The same markup, pathLength and all, with only the stroke styles:
// scrollDraw sets the dash itself.
scrollDraw('.draw', { stagger: 0.2, once: true });With once the drawing stays drawn after the reader scrolls back, and under reduced motion it is shown finished. A plain call with no stagger and no once writes the view-timeline CSS above itself where the browser supports it and runs on JavaScript elsewhere; the native CSS path post lists exactly when.
— / next
Paste an SVG, get the CSS
The line drawing generator writes all of this for any SVG: pathLength on every shape, the stagger, a loop, the reduced-motion rule, and a scroll version.
Star on GitHubFound this useful? A star helps others find it.