XOBOID/WRITINGS/svg-stroke-animation
Engineering·June 1, 2025·4 min read

Animating SVG Paths with CSS

A deep dive into SVG stroke animation using stroke-dashoffset and CSS transitions — from basic line drawing to complex morphing effects.

SVG path animation is one of those techniques that looks complex but reduces to two CSS properties. Once you understand stroke-dasharray and stroke-dashoffset, drawing lines, revealing shapes, and building loaders all become trivial.

The core trick

Every SVG stroke can be broken into a sequence of dashes and gaps. If you set the dash length equal to the total path length, the entire path becomes one single dash. Set the offset to the same value, and you've pushed that dash completely out of view — the path looks invisible.

base.svg
<svg viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg">
  <path
    d="M 10 50 C 60 10, 140 10, 190 50"
    fill="none"
    stroke="#FF3B00"
    stroke-width="2"
    class="line"
  />
</svg>

Now animate the offset from full-length back to zero:

animation.css
.line {
  stroke-dasharray: 230;   /* total path length */
  stroke-dashoffset: 230;  /* start fully hidden */
  animation: draw 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
 
@keyframes draw {
  to {
    stroke-dashoffset: 0;  /* fully revealed */
  }
}

The result: the path draws itself from left to right on load.


Getting the path length

You can't always eyeball the length. Use JavaScript to measure it precisely:

measure.js
const path = document.querySelector('.line')
const length = path.getTotalLength()
console.log(length) // → 230.47...

Or, if you're working at build time in Node, use the svgpathdata or svg-path-properties packages to compute it statically — no DOM required.

Staggered multi-path reveal

The same approach scales to complex illustrations. Give each path a --delay custom property and stagger them:

DrawingHero.tsx
const paths = [
  { d: "M 10 50 C 60 10, 140 10, 190 50", length: 230 },
  { d: "M 10 80 L 190 80",                 length: 180 },
  { d: "M 100 10 L 100 90",                length: 80  },
]
 
export function DrawingHero() {
  return (
    <svg viewBox="0 0 200 100" className="w-full">
      {paths.map(({ d, length }, i) => (
        <path
          key={i}
          d={d}
          fill="none"
          stroke="currentColor"
          strokeWidth={1.5}
          style={{
            strokeDasharray: length,
            strokeDashoffset: length,
            animation: `draw 0.9s cubic-bezier(0.16,1,0.3,1) ${i * 120}ms forwards`,
          }}
        />
      ))}
    </svg>
  )
}

Each path begins drawing 120ms after the previous one — creating a satisfying cascade.


Scroll-triggered drawing

Pair it with IntersectionObserver to only trigger when the element enters the viewport:

useDrawOnScroll.ts
import { useEffect, useRef } from "react"
 
export function useDrawOnScroll() {
  const ref = useRef<SVGPathElement | null>(null)
 
  useEffect(() => {
    const el = ref.current
    if (!el) return
 
    const length = el.getTotalLength()
    el.style.strokeDasharray = String(length)
    el.style.strokeDashoffset = String(length)
 
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          el.style.transition = "stroke-dashoffset 1s cubic-bezier(0.16,1,0.3,1)"
          el.style.strokeDashoffset = "0"
          observer.disconnect()
        }
      },
      { threshold: 0.3 }
    )
 
    observer.observe(el)
    return () => observer.disconnect()
  }, [])
 
  return ref
}

Use it like any other hook — attach ref to your <path> element and the animation fires once on scroll.

Performance notes

  • CSS animations run on the compositor thread. strokeDashoffset triggers no layout recalculations — it's paint-only and generally silky smooth.
  • Avoid animating d (the path data attribute) directly unless you need morphing; it's expensive and only works when paths have the same number of nodes.
  • For paths longer than ~2000px, consider splitting them into segments or using a clip-path approach instead.

The stroke trick is essentially a hack that never got replaced because it works so well. It's been in production at Apple, GitHub, and countless other animations for years.


Morphing paths

Morphing requires identical point counts. With @keyframes on the d property (now widely supported):

morph.css
@keyframes morph {
  0%   { d: path("M 10 90 L 190 90"); }
  50%  { d: path("M 10 90 Q 100 10 190 90"); }
  100% { d: path("M 10 90 L 190 90"); }
}
 
.morph-path {
  animation: morph 2s ease-in-out infinite alternate;
}

Both paths share 3 points: start, control, end. The browser interpolates cleanly between them.


That's the full toolkit. stroke-dashoffset for reveals, IntersectionObserver for scroll triggers, and CSS d interpolation for morphing. All compositable, all performant.