EmpireUI
Get Pro
← Blog9 min read#svg#animation#stroke-dasharray

Advanced SVG Animation: stroke-dasharray, SMIL and JavaScript Sync

Master SVG animation with stroke-dasharray path tricks, SMIL declarative timing, and JavaScript sync for precise, performant motion that CSS alone can't do.

colorful abstract SVG vector paths glowing on dark digital background

Why SVG Animation Is Worth the Complexity

CSS handles most UI motion just fine. Fades, slides, scale bounces — done. But the moment you want a logo that draws itself, a progress ring that animates precisely, or a path that traces a route on a map, you've hit CSS's ceiling. SVG animation lives in a different tier.

Honestly, the main reason developers avoid it is the initial syntax shock. stroke-dasharray looks weird the first time. SMIL feels like XML from 2003 (it basically is — SVG 1.1 shipped in 2003). But once you understand the mental model, you'll reach for this toolset constantly. It's one of those things that unlocks a whole class of UI effects you previously had to fake with GIFs or Lottie files.

SVG animation also gives you genuine resolution independence. A path-drawing animation on a 4K retina display looks identical to one on a 720p monitor — no blurry raster frames, no file size scaling with resolution. That matters when you're building something that's supposed to feel premium. If you're already working with design-forward UIs — say, something in the aurora or cyberpunk style families — SVG motion is often the missing ingredient that makes a UI feel alive rather than static.

This article is about three techniques specifically: the stroke-dasharray / stroke-dashoffset trick for path drawing, SMIL's declarative animation model (and when it's actually better than JavaScript), and how to synchronize SVG animation with JavaScript for timeline control, scroll triggers, and reactive state.

stroke-dasharray and stroke-dashoffset: The Path Drawing Trick

This is the foundational technique. Every SVG stroke can be rendered as a dashed line. stroke-dasharray defines the length of dash and gap segments. stroke-dashoffset shifts that pattern along the path. Set both to the full path length, then animate stroke-dashoffset to 0 — the stroke appears to draw itself from start to end.

The magic number you need is path.getTotalLength(). This is a DOM method that returns the exact pixel length of any SVG path element at its current rendered size. You need this at runtime — you can't hardcode it reliably across different viewport sizes unless the SVG has a fixed viewBox and you never scale it.

Here's the minimal JavaScript setup: ``js const path = document.querySelector('#my-path'); const length = path.getTotalLength(); // Prime the dash path.style.strokeDasharray = length; path.style.strokeDashoffset = length; // Animate with CSS transition path.style.transition = 'stroke-dashoffset 2s ease-in-out'; path.style.strokeDashoffset = '0'; ` That's it. Two lines of setup, one transition declaration, one value change. Worth noting: you need to wait one animation frame between setting the initial strokeDashoffset and triggering the transition, or the browser collapses both writes into a single paint and you see nothing. Use requestAnimationFrame or a setTimeout(fn, 16)` as a cheap workaround.

For multi-segment paths or icons with multiple <path> elements, you run the same setup per element and stagger the transition delays: ``js document.querySelectorAll('.icon-path').forEach((path, i) => { const len = path.getTotalLength(); path.style.strokeDasharray = len; path.style.strokeDashoffset = len; path.style.transition = stroke-dashoffset 0.6s ease ${i * 0.12}s; }); // Trigger after one frame requestAnimationFrame(() => { document.querySelectorAll('.icon-path').forEach(p => { p.style.strokeDashoffset = '0'; }); }); `` That 120ms stagger (0.12s per element) tends to feel natural. Go above 200ms per step and it starts to drag.

One more thing — stroke-dasharray also accepts multiple values: stroke-dasharray: 10 5 3 5 creates alternating dash/gap patterns. You can animate this property directly in CSS @keyframes to create living dotted lines, loading spinners that feel hand-drawn, or signal-pulse effects on diagram connectors. Not everything has to be a full draw-on animation.

SMIL: Declarative SVG Animation Without JavaScript

SMIL (Synchronized Multimedia Integration Language) is the XML-based animation system baked into SVG. You declare animations directly inside the SVG markup. Chrome threatened to deprecate it around 2015-2016, then reversed that decision. In 2026 it ships in every major browser, and it's genuinely useful for a specific category of work.

The core element is <animate>. You drop it inside any SVG shape element and describe what changes, over what duration, and how: ``xml <circle cx="50" cy="50" r="20" fill="none" stroke="#a78bfa" stroke-width="3"> <animate attributeName="r" values="20;35;20" dur="1.8s" repeatCount="indefinite" calcMode="spline" keySplines="0.4 0 0.6 1; 0.4 0 0.6 1" /> <animate attributeName="opacity" values="1;0.3;1" dur="1.8s" repeatCount="indefinite" /> </circle> ` That's a pulsing sonar-style ring, no JavaScript. The calcMode="spline" with keySplines` is how you get cubic-bezier easing in SMIL — the syntax is ugly but powerful.

For path morphing between two shapes, <animate> on attributeName="d" works if both paths have the same number of points and commands. This is the declarative version of what Flubber or GSAP's MorphSVG does, and it's zero-dependency: ``xml <path id="morphing" fill="#7c3aed"> <animate attributeName="d" dur="3s" repeatCount="indefinite" values="M10,30 A20,20,0,0,1,50,30 A20,20,0,0,1,90,30 Q90,60,50,90 Q10,60,10,30 Z; M10,50 Q35,10,50,50 Q65,90,90,50 Q65,10,50,50 Q35,90,10,50 Z; M10,30 A20,20,0,0,1,50,30 A20,20,0,0,1,90,30 Q90,60,50,90 Q10,60,10,30 Z" /> </path> `` In practice, SMIL is best for looping ambient animations that don't need to respond to user interaction or application state. Think: background particles, decorative loader rings, icon hover effects that are always playing. The moment you need to start/stop/pause based on scroll position or a React state change, you'll want JavaScript.

Quick aside: <animateTransform> handles translate, rotate, scale, and skewX/Y as attributes directly — no need for CSS transforms on SVG elements, which can behave inconsistently across browsers when transform-origin is involved. SVG's coordinate system means transform-origin for CSS doesn't work the same as it does on HTML elements prior to Chrome 112.

Where SMIL wins decisively: inline SVG in HTML email. JavaScript doesn't run, CSS animations are stripped by most clients, but SMIL plays in Apple Mail, iOS Mail, and some Outlook versions. That's a narrow use case but a real one.

JavaScript Sync: Timeline Control and Scroll-Triggered SVG

Declarative animation is great until you need it to react to something. Scroll position, a form submission, a WebSocket event, a user clicking a 48px button at exactly the wrong moment — these all require programmatic control. This is where you sync SVG animation with JavaScript timelines.

The lowest-level approach is the Web Animations API (WAAPI), which ships natively in every modern browser and gives you a play(), pause(), reverse(), and currentTime interface that works directly on SVG elements: ``js const path = document.querySelector('#signal-line'); const len = path.getTotalLength(); const anim = path.animate( [ { strokeDashoffset: len }, { strokeDashoffset: 0 } ], { duration: 1200, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', fill: 'forwards' } ); anim.pause(); // hold until we need it // Trigger on scroll const observer = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) anim.play(); }); }, { threshold: 0.3 }); observer.observe(document.querySelector('#section-trigger')); ` No libraries. No dependencies. That threshold: 0.3` means the animation fires when 30% of the trigger element is visible — adjust to taste.

For timeline synchronization across multiple SVG elements — like an SVG diagram where nodes light up in sequence as you explain a concept — you can chain WAAPI animations using .finished promises: ``js async function runDiagramSequence(nodes) { for (const node of nodes) { const anim = node.animate( [{ opacity: 0, transform: 'scale(0.8)' }, { opacity: 1, transform: 'scale(1)' }], { duration: 400, easing: 'ease-out', fill: 'forwards' } ); await anim.finished; // wait for each before starting next } } ` That for...of with await anim.finished` pattern is underused. It reads clearly, handles any sequence length, and you can add delays with a small helper if needed.

Look, if you need scrub-controlled animation where the SVG responds to exact scroll position (not just enter/exit), WAAPI's currentTime is your hook: ``js const totalDuration = 2000; // ms, treated as a timeline unit window.addEventListener('scroll', () => { const scrollPct = window.scrollY / (document.body.scrollHeight - window.innerHeight); anim.currentTime = scrollPct * totalDuration; }); // Also pause the animation so it doesn't auto-play anim.pause(); `` This gives you a scroll-scrubbed SVG path draw. Smooth, 60fps, no GSAP ScrollTrigger required (though GSAP does make this much easier on complex timelines — it's a tradeoff between dependency weight and authoring speed).

One underrated pattern: syncing SVG stroke animation with audio. The Web Audio API exposes AudioContext.currentTime with sub-millisecond precision. If you're building a waveform visualizer or a music-reactive UI, you can set SVG element attributes directly from an AnalyserNode requestAnimationFrame loop. That's 60fps DOM mutation via setAttribute, which is fast enough for most cases but you'd want to profile at element counts above ~50.

React Integration: Refs, Hooks, and Framer Motion SVG

In a React codebase, all of this goes through refs. You can't query document.querySelector inside effects reliably without risking hydration mismatches in Next.js. The pattern is: ref on the SVG element, getTotalLength() inside a useEffect with an empty dependency array, state or ref for the animation object. ``tsx import { useEffect, useRef } from 'react'; export function DrawingPath({ d, color = '#7c3aed' }: { d: string; color?: string }) { const pathRef = useRef<SVGPathElement>(null); useEffect(() => { const path = pathRef.current; if (!path) return; const len = path.getTotalLength(); path.style.strokeDasharray = String(len); path.style.strokeDashoffset = String(len); requestAnimationFrame(() => { path.style.transition = 'stroke-dashoffset 1.5s ease-in-out'; path.style.strokeDashoffset = '0'; }); }, []); return ( <svg viewBox="0 0 200 200" fill="none"> <path ref={pathRef} d={d} stroke={color} strokeWidth="2" /> </svg> ); } ``

For Framer Motion users, the pathLength motion value is the clean abstraction over all of this. It normalizes stroke-dasharray and stroke-dashoffset to a 0-1 range so you don't have to call getTotalLength() at all: ``tsx import { motion } from 'framer-motion'; <motion.path d="M10 80 Q 95 10 180 80" stroke="#a78bfa" strokeWidth="3" fill="none" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 1.8, ease: 'easeInOut' }} /> ` Honestly, if you're already using Framer Motion for your React UI — and if you're building with tools from the [Empire UI](/), you probably are — just use pathLength`. It's the right abstraction.

That said, Framer Motion doesn't expose SMIL at all, and it adds 100KB+ to your bundle. For a single animated logo or icon component, the native WAAPI approach with refs is leaner. It's a classic bundle-vs-ergonomics tradeoff and neither answer is universally correct.

Worth noting: React 18's useId can help you avoid SVG id clashes when you render the same animated SVG component multiple times. SVG clipPath, mask, and filter elements reference other elements by id, and duplicate ids in the same document cause silent failures. Use useId to generate stable, unique ids per component instance.

Performance Gotchas and What to Actually Measure

SVG animation has a reputation for jank that's mostly deserved when you're animating the wrong properties. Animating d (path data) or r on a complex shape forces layout recalculation on every frame. Animating stroke-dashoffset, opacity, and CSS transform stays on the compositor thread — that's 60fps territory.

The fast list: opacity, transform (CSS, not SVG transform attribute), stroke-dashoffset, fill-opacity. The slow list: d, points, r on large circles, width/height, x/y positioning via attributes instead of transform. If you're seeing dropped frames, open Chrome DevTools Performance tab, record a 3-second trace, and look for long "Rendering" blocks. Switching from cx/cy animation to transform: translate() on the same circle will often turn a 14ms frame into a 2ms frame.

For complex scenes with 20+ animated SVG elements, consider moving them into a <canvas> renderer. You can use CanvasRenderingContext2D to draw SVG paths via Path2D, which gives you GPU-accelerated compositing without the DOM overhead. It's more work to set up but a real option when you're building data visualizations or game-adjacent UIs.

Also: SVG filters (<feGaussianBlur>, <feTurbulence>) are expensive when animated. A single animated <feTurbulence baseFrequency> on a large element can tank your framerate on mid-tier mobile. If you're building glassmorphism components or any effect that layers blur on top of SVG, test on a 2021 Android device, not your M3 MacBook. The gap is brutal.

One final note on file size. Exported SVGs from Figma or Illustrator are rarely optimized. Run them through SVGO before embedding them in your app. The difference between a raw 18KB export and an optimized 4KB version is real, and smaller path data means getTotalLength() runs faster too. Small wins compound.

FAQ

Is SMIL animation deprecated in modern browsers?

No. Chrome reversed its deprecation notice back in 2016 and SMIL is fully supported across Chrome, Firefox, and Safari in 2026. It's a legitimate tool, not legacy baggage.

Why does my stroke-dashoffset animation not play in React?

You're almost certainly setting the initial dashoffset and transition in the same tick. The browser batches both writes and skips the animation. Wrap the transition trigger in a requestAnimationFrame callback after setting the initial value.

Should I use Framer Motion's pathLength or the native Web Animations API for SVG?

Use pathLength if Framer Motion is already in your project — it's cleaner and avoids manual getTotalLength() calls. Use WAAPI if you're building something lightweight and don't want the bundle cost.

Can I animate SVG path morphing between two different shapes?

Yes, but both paths must have the same number of points and the same SVG command types. If they don't match, you'll get garbled interpolation — use a library like Flubber to normalize paths before morphing.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

HTML Canvas Animations in React: Particles, Noise Fields, MoreCanvas Particle System From Scratch: Mouse Interaction, Color FieldsAnime.js v4 in 2026: Timeline, Stagger and SVG AnimationsSVG Animation in React: stroke-dashoffset, SMIL and Framer Motion