SVG Animation in React: stroke-dashoffset, SMIL and Framer Motion
Master SVG animation in React — from stroke-dashoffset draw-on effects to SMIL vs Framer Motion trade-offs, with code you can drop in today.
Why SVG Animation Is Still Worth Your Time
Canvas is powerful. CSS transforms are fine. But SVG animation hits a sweet spot that neither fully covers — you get resolution-independent paths, first-class DOM accessibility, and a coordinate system you can reason about without a maths degree. In 2026, with React 19 and Framer Motion 11 in the wild, SVG animation is genuinely easier than it's ever been.
That said, the space is fragmented. You've got SMIL (built into the SVG spec), CSS animation on SVG properties, JavaScript-driven approaches with useRef and requestAnimationFrame, and Framer Motion's declarative API. Each has a real use case. Picking the wrong one adds complexity without buying you anything.
Look, if you're animating a loading spinner or a simple icon state change, you don't need Framer Motion. But if you're building an interactive data viz or orchestrating a sequence of path draws tied to scroll position, a library is worth every kilobyte. We'll cover all the tiers.
stroke-dashoffset: The Core Trick
The draw-on effect — where a path appears to be hand-drawn across the screen — is entirely powered by two SVG attributes: stroke-dasharray and stroke-dashoffset. Here's the mental model: stroke-dasharray sets the length of the dash. Set it to the total path length and you have one continuous dash covering the whole path. stroke-dashoffset then shifts that dash forward, hiding it. Animate offset from pathLength to 0 and the path draws itself.
Getting the path length right is the annoying part. The SVG spec gives you getTotalLength() on any SVGGeometryElement, but calling it server-side (in SSR/Next.js) blows up because the DOM doesn't exist. The reliable fix is a useEffect with a ref:
``tsx
import { useEffect, useRef } from 'react'
export function DrawPath({ d }: { d: string }) {
const pathRef = useRef<SVGPathElement>(null)
useEffect(() => {
const path = pathRef.current
if (!path) return
const len = path.getTotalLength()
path.style.strokeDasharray = ${len}
path.style.strokeDashoffset = ${len}
// trigger reflow so the browser registers the initial state
path.getBoundingClientRect()
path.style.transition = 'stroke-dashoffset 1.4s ease-in-out'
path.style.strokeDashoffset = '0'
}, [])
return (
<svg viewBox="0 0 200 200" fill="none" stroke="#6366f1" strokeWidth={2}>
<path ref={pathRef} d={d} />
</svg>
)
}
``
Worth noting: the forced reflow (getBoundingClientRect()) before setting the final offset is critical. Without it, some browsers skip straight to the end state because they batch the style mutations together. It's a 1-liner but it trips up a lot of people the first time.
Honestly, the hardest part of stroke-dashoffset tricks isn't the CSS — it's wrangling path lengths for complex shapes. If you're using icon libraries that ship pre-built SVG paths, lengths vary wildly: a 24px Heroicon might have a total length of around 47px, while a detailed illustration path can hit 2000px+. Always measure at runtime, not compile time.
SMIL: The One Built Into SVG
SMIL (Synchronized Multimedia Integration Language) is SVG's native animation system. It predates CSS animation and lets you declare animations directly inside the SVG markup with elements like <animate>, <animateTransform>, and <animateMotion>. No JavaScript, no library — just attributes.
``svg
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="#ec4899" strokeWidth="4"
strokeDasharray="251.2"
strokeDashoffset="251.2">
<animate
attributeName="stroke-dashoffset"
from="251.2"
to="0"
dur="1.5s"
fill="freeze"
begin="0s" />
</circle>
</svg>
``
The appeal is zero runtime cost. The SVG itself carries the animation — no React hydration, no JS execution, no Framer Motion bundle. For icons in emails or SVGs inlined into static HTML, SMIL is the right call. It also survives Content Security Policy restrictions that would block inline scripts.
Quick aside: Chrome briefly removed SMIL support in 2015 before reversing course. It's back and stable across all major browsers as of mid-2023. But Safari had known bugs with calcMode="spline" easing before version 16.4, so if you're doing anything fancy with custom bezier easing in SMIL, test on Safari 16 explicitly.
The downside? SMIL is effectively read-only. You can't easily respond to user interactions, pause/resume based on scroll, or sequence animations conditionally. The moment your animation has any runtime logic in it, SMIL becomes a dead end and you're reaching for JS anyway.
Framer Motion's SVG Support
Framer Motion handles SVG elements as first-class citizens since version 10. You wrap your SVG elements with motion.* variants and the library figures out interpolation. For stroke-dashoffset, there's a shortcut: the pathLength prop, which normalizes the value to 0–1 so you never have to call getTotalLength() manually.
Here's the same draw-on effect from earlier, this time with Framer Motion 11:
``tsx
import { motion } from 'framer-motion'
export function AnimatedPath({ d }: { d: string }) {
return (
<svg viewBox="0 0 200 200" fill="none" stroke="#6366f1" strokeWidth={2}>
<motion.path
d={d}
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{
pathLength: { duration: 1.6, ease: 'easeInOut' },
opacity: { duration: 0.3 }
}}
/>
</svg>
)
}
``
That pathLength: 1 is doing the heavy lifting behind the scenes — Framer Motion calls getTotalLength() internally and maps it for you. The trade-off is bundle size: Framer Motion adds roughly 45kb gzipped to your client bundle. For a dashboard full of animated charts, that's acceptable. For a single animated icon on a marketing page, it probably isn't.
Where Framer Motion genuinely shines is in gesture-driven and scroll-linked SVG animations. The useScroll + useTransform combo lets you drive pathLength or strokeDashoffset directly from scroll position, which is how you build those satisfying progress-bar-along-a-path effects. This is something SMIL simply can't do and vanilla CSS can only approximate with scroll-timeline (which has limited browser support as of late 2025).
``tsx
import { motion, useScroll, useTransform } from 'framer-motion'
import { useRef } from 'react'
export function ScrollDrawPath({ d }: { d: string }) {
const ref = useRef(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'end start'] })
const pathLength = useTransform(scrollYProgress, [0, 1], [0, 1])
return (
<div ref={ref} style={{ height: '300vh' }}>
<svg viewBox="0 0 300 600" fill="none" stroke="#a855f7" strokeWidth="3"
style={{ position: 'sticky', top: '20px' }}>
<motion.path d={d} style={{ pathLength }} />
</svg>
</div>
)
}
``
CSS Animation on SVG Properties: The Middle Ground
You don't always need JavaScript. CSS animations work on most SVG presentation attributes, and the browser's compositor thread handles them without touching the main thread. For a looping spinner that never interacts with app state, pure CSS is the fastest path:
``css
.spinner-ring {
stroke-dasharray: 188.5;
stroke-dashoffset: 188.5;
animation: draw 1.2s ease-in-out infinite alternate;
transform-origin: center;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
``
In React you'd apply this via a className or CSS Module. The performance story is good — these run at 60fps even on mid-range Android devices because the browser treats stroke-dashoffset as a compositable property in modern engines. Worth noting: as of Chrome 112 (released April 2023), stroke-dashoffset no longer causes layout recalculations, so the old advice to will-change: stroke-dashoffset is no longer necessary and actually wastes GPU memory.
In practice, CSS animation breaks down when you need to know when an animation ends. The animationend event exists but feels fragile in React. If you need to chain animations or update component state on completion, reach for Framer Motion's onAnimationComplete callback or a vanilla JS approach with useEffect cleanup.
One more thing — CSS custom properties work inside SVG keyframes. You can expose --draw-speed or --stroke-color from your component and vary them per-instance without duplicating keyframe blocks. This is underused and genuinely elegant for component libraries like Empire UI where you want consistent animation timing tokens across dozens of components.
animateMotion: Moving Elements Along a Path
Different from draw-on effects, animateMotion moves an element *along* a path — like a dot travelling a route or an icon following a curved trajectory. SMIL has native <animateMotion> for this, and it's one of the few cases where SMIL still wins on simplicity:
``svg
<svg viewBox="0 0 200 100">
<path id="track" d="M10,50 Q100,10 190,50" fill="none" stroke="#e2e8f0" strokeWidth="1"/>
<circle r="6" fill="#6366f1">
<animateMotion dur="2s" repeatCount="indefinite">
<mpath href="#track" />
</animateMotion>
</circle>
</svg>
``
Framer Motion doesn't have a built-in animateMotion equivalent, but you can approximate it with useMotionValue, useTransform, and a manual point-on-path calculation using the same getTotalLength + getPointAtLength API. It's more code but gives you full control over easing, direction, and interaction.
``tsx
import { motion, useMotionValue, useTransform, animate } from 'framer-motion'
import { useEffect, useRef } from 'react'
export function DotOnPath({ pathD }: { pathD: string }) {
const pathRef = useRef<SVGPathElement>(null)
const progress = useMotionValue(0)
const x = useTransform(progress, (p) => {
if (!pathRef.current) return 0
const len = pathRef.current.getTotalLength()
return pathRef.current.getPointAtLength(p * len).x
})
const y = useTransform(progress, (p) => {
if (!pathRef.current) return 0
const len = pathRef.current.getTotalLength()
return pathRef.current.getPointAtLength(p * len).y
})
useEffect(() => {
const controls = animate(progress, 1, { duration: 2, repeat: Infinity })
return controls.stop
}, [progress])
return (
<svg viewBox="0 0 200 100" overflow="visible">
<path ref={pathRef} d={pathD} fill="none" stroke="#e2e8f0" strokeWidth="1" />
<motion.circle r={6} fill="#6366f1" style={{ x, y }} />
</svg>
)
}
``
Honestly, for simple non-interactive motion paths, SMIL's <animateMotion> is cleaner and ships zero JS. But the moment you want the dot to pause when the user hovers, or reverse on click, you need the Framer Motion approach above. Don't over-engineer it if SMIL covers your case.
The styles you can build with animated SVG paths integrate naturally into design systems that lean on motion as a design language. If you're exploring UI styles with strong visual personalities — think cyberpunk or aurora — animated SVG is often the thing that ties the aesthetic together. A glowing path trace hits differently than a CSS box-shadow pulse.
Choosing the Right Tool for Your Situation
Here's the honest decision tree. Static looping animation with no state? Use CSS @keyframes on stroke-dashoffset. Self-contained SVG that needs to work without JavaScript — in emails, PDFs exported from browser, or behind strict CSPs? Use SMIL. Interactive, scroll-driven, or sequenced animation in a React app where bundle size is already committed? Use Framer Motion.
Performance-wise, all three approaches can be janky if you're animating the wrong properties. Stick to stroke-dashoffset, opacity, and transform — they're the compositable properties. Animating stroke-width, fill, or d (the path data itself for morphing) triggers style recalculation on every frame and will hurt on low-end hardware. If you need path morphing between two d values, Framer Motion's d interpolation works but keep path node counts identical between start and end states.
Quick aside: React 19's concurrent rendering doesn't change anything fundamental about how SVG animations work, but it does mean you should be careful about kicking off animate() calls inside effects that run twice in Strict Mode. Always return a cleanup function from your useEffect to stop any running animations, or you'll see doubled-up animation speeds in development.
``tsx
useEffect(() => {
const controls = animate(progress, 1, { duration: 1.5 })
return () => controls.stop() // critical in React 19 strict mode
}, [progress])
``
The gradient generator and box shadow generator on Empire UI are good examples of tools where SVG plays a supporting role — small animated previews, path-based icons — without the animation being the primary feature. That's the most common real-world use case: subtle motion that supports UI rather than dominates it. You don't always need the full draw-on cinematic. Sometimes a 200ms stroke-dashoffset fade on an icon hover is exactly right.
FAQ
It works on the path element itself, but clip-path and masks can visually cut off the drawing effect mid-stroke. Test with your specific icon — if it clips, move the stroke-dashoffset animation to a child path that's outside the clipping context.
Yes. Framer Motion defers the getTotalLength() call to the client, so SSR won't crash. The element will just render at its initial state (pathLength: 0 if that's your initial) and animate on hydration. No extra workarounds needed.
fill="freeze" holds the element at its final animation state after it completes. fill="remove" (the default) snaps it back to the initial value. For draw-on effects you almost always want freeze, otherwise the path disappears the moment the animation ends.
Use the Intersection Observer API in a useEffect to detect when the SVG enters view, then either add a CSS class that starts the animation or set a state variable that triggers a Framer Motion animate call. Framer Motion's whileInView prop is the shortest path if you're already using the library.