13 / blog / how-toNovember 20267 min read

How to animate an SVG line drawing with CSS

An SVG line that draws itself takes two stroke properties and one keyframe. Add pathLength="1" and no shape needs measuring. Here it is for one path, for several in order, on a loop, for readers who ask for less motion, and on scroll, with every listing run in Chromium, Firefox and WebKit.

  • How-To
  • stroke-dasharray
  • pathLength
  • CSS animation
JavaScript
none
Properties
dasharray · dashoffset
Shape length
pathLength="1"
1
0.5
0
Fig. 1 — one triangle with pathLength="1" and stroke-dasharray: 1 2 at three offsets, drawn by the browser from those values. The offset slides the dash back along the line; only the part inside the line shows.
01The trick

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.

draw.html3 lines
<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.css15 lines
.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.

measure.js6 lines
// 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;
}
02Stagger

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.

house.html6 lines
<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>
house.css10 lines
.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.

03Infinite

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.css13 lines
.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:

loop-many.css22 lines
/* 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.

04Accessibility

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.

reduced.css16 lines
/* 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.

05Scroll

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.

scroll.css23 lines
.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:

scroll.js5 lines
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.

→Parts list

Keep reading

More from the blog