Drawing SVG Lines with stroke-dashoffset
Part of Animating SVG with CSS in Core CSS Animation Fundamentals.
The problem
A signature, a route on a map, an underline beneath a heading or a checkmark after a successful payment should appear as if drawn by a pen. The effect is well known, but the usual implementation is fragile: a script calls getTotalLength() on each path, writes the result into stroke-dasharray and stroke-dashoffset, and the animation breaks the moment the path is edited, the SVG is resized responsively or the script runs before fonts and layout settle.
Multi-path drawings compound it. Each path has a different length, so a shared duration makes short strokes crawl and long strokes race, and sequencing them requires more measurement.
Root cause analysis: dashes are measured in user units
stroke-dasharray describes a repeating pattern of dash and gap lengths along a stroke. stroke-dashoffset shifts where that pattern starts. The drawing trick uses a pattern with one dash as long as the whole path, followed by a gap at least as long: with an offset equal to the length, the dash is shifted entirely into the gap and nothing is visible; with an offset of zero, the dash covers the path. Animating the offset between the two reveals the stroke from its start point.
Both values are measured along the path in the SVG’s user units by default, which is why scripts measure. A 300-unit path needs 300; a 42-unit path needs 42.
The pathLength attribute removes that dependency. It tells the browser to treat the path as if it were that long for the purpose of dash and offset calculations, scaling the values proportionally. With pathLength="1", a dash array of 1 is always exactly the path’s full length and an offset of 1 always hides it — for every path, at every rendered size, without measuring anything.
Step-by-step resolution
Direction is set by the path data: the stroke is revealed from the first point of the d attribute. If a line draws backwards, reverse the path in the editor rather than trying to animate the offset to negative values, which works for single paths but is harder to reason about for sequences.
Production code pattern
<svg class="route" viewBox="0 0 320 120" role="img" aria-label="Route from the station to the venue">
<path style="--i: 0" pathLength="1" d="M12 100 C 60 20, 120 20, 160 60" />
<path style="--i: 1" pathLength="1" d="M160 60 S 240 110, 300 30" />
<circle class="route__pin" cx="300" cy="30" r="6" />
</svg>
.route path {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 1; /* one dash as long as the (normalised) path, then a gap */
stroke-dashoffset: 1; /* shifted fully into the gap: invisible */
}
.route.is-visible path {
animation: draw 700ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
animation-delay: calc(var(--i) * 600ms); /* start each segment as the previous one ends */
}
.route__pin {
opacity: 0;
transform-box: fill-box;
transform-origin: center;
}
.route.is-visible .route__pin {
animation: pin 240ms ease-out 1300ms forwards; /* after both segments */
}
@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes pin { from { transform: scale(0.4); } to { opacity: 1; transform: scale(1); } }
@media (prefers-reduced-motion: reduce) {
.route path { stroke-dashoffset: 0; } /* drawn from the start */
.route.is-visible path,
.route.is-visible .route__pin { animation: none; }
.route__pin { opacity: 1; }
}
Rendering Impact: paint. Each frame repaints the region covered by the stroke being drawn, bounded by the SVG’s size. The pin’s scale is a transform on an SVG shape, which also paints; for a 320-unit graphic both costs are small.
The stroke-linecap: round detail has a side effect: a round cap extends past the dash’s end, so at offset 1 a dot can remain visible at the start of each path. If it shows, animate from a slightly larger offset such as 1.01, or start with stroke-linecap: butt and switch at the end.
The is-visible class is typically added by an IntersectionObserver, or you can drive the drawing directly from scroll. A scroll-driven animation with animation-timeline: view() ties the offset to the graphic’s position in the viewport, so the route draws as the user scrolls through it — still with no measurement.
Verification checklist
Constraints and trade-offs
- Line drawing is a paint animation; a full-viewport illustration drawing dozens of paths will cost several milliseconds per frame on phones.
pathLengthaffects all dash calculations on that path, so a decorative dashed pattern must also be expressed in fractions.- Paths with
vector-effect: non-scaling-strokeor strokes that change width along their length can reveal unevenly at the ends. - Text rendered as SVG
<text>cannot be drawn this way; convert it to outlines first, which removes it from the accessibility tree. - Filled shapes do not draw — only strokes do. A handwriting effect on a filled glyph needs a masking stroke path over it.
Frequently asked questions
Why use pathLength instead of getTotalLength()?
pathLength normalises the dash calculations in markup, so the CSS works for any path, at any rendered size, without script or timing concerns. getTotalLength() needs the path to be in the DOM, needs rerunning when the path changes, and ties the CSS values to one specific geometry.
Can I draw the line in the reverse direction?
Yes. Either reverse the path data so it starts at the other end, or animate stroke-dashoffset from -1 to 0 instead of 1 to 0. Reversing the path is clearer when several segments are sequenced.
Is stroke-dashoffset animation composited?
No. Changing the dash offset changes what is painted, so each frame repaints the stroke’s region. Keep drawings small, or isolate a large one in its own svg element so the repaint does not include the rest of the page.
How do I draw on scroll instead of on load?
Replace the time-based animation with a scroll-driven one: keep the same keyframes and set animation-timeline: view() with an animation-range on the svg’s paths. The offset then follows the graphic’s progress through the viewport.
Related
- Animating SVG with CSS — the parent topic on SVG properties and their costs
- Morphing SVG Paths with the d Property — changing the shape rather than revealing it
- Reveal on Scroll Without IntersectionObserver — triggering the drawing from scroll position