Files
cycle-atlas/.agents/skills/svg-animation/references/svg-techniques.md

8.8 KiB

SVG Animation Techniques (detailed)

1. Stroke draw-on: the math

stroke-dasharray defines a dash pattern; stroke-dashoffset shifts where the pattern starts. Set the dash equal to the whole path length and offset it by that same length → the visible dash is pushed entirely off the path (invisible). Animating offset to 0 slides the dash into view, revealing the stroke progressively.

visible fraction = 1 - (dashoffset / pathLength)

So offset = length → 0% drawn; offset = 0 → 100% drawn; offset = length/2 → 50% drawn (useful for scroll mapping: offset = length * (1 - progress)).

getTotalLength gotchas

const path = document.querySelector("#sig");
const len = path.getTotalLength();           // returns user units of the path geometry
path.style.strokeDasharray = len;
path.style.strokeDashoffset = len;
requestAnimationFrame(() => {                 // next frame so the transition has a start value
  path.style.transition = "stroke-dashoffset 1.6s ease";
  path.style.strokeDashoffset = "0";
});
  • getTotalLength() measures the path's own coordinate system, ignoring CSS transforms/scaling — which is what you want for dasharray.
  • It only works on geometry elements (path, line, polyline, polygon, circle, ellipse, rect). For <text> draw-on you must convert text to paths first.
  • Rounding: browsers can clip the last sub-pixel; pad slightly (len + 1) if the end never fully closes.
  • Compound paths (multiple M subpaths in one d) draw all subpaths simultaneously. Split into separate <path>s with staggered animation-delay for sequential drawing.

No-JS normalization with pathLength

pathLength="1" tells the browser to treat the path's length as exactly 1, regardless of real geometry. Then dash math is unit-free:

<path d="..." pathLength="1" fill="none" stroke="#111"
      style="stroke-dasharray:1; stroke-dashoffset:1; animation:draw 1.4s ease forwards"/>
@keyframes draw { to { stroke-dashoffset: 0; } }

This is the cleanest pure-CSS draw-on and avoids hardcoding measured lengths that break when the path is edited. Use pathLength="100" if you prefer to think in percentages.

Scroll-driven draw-on (no library, via offset)

const len = path.getTotalLength();
path.style.strokeDasharray = len;
function onScroll() {
  const p = Math.min(1, Math.max(0, scrollProgress())); // 0..1
  path.style.strokeDashoffset = len * (1 - p);
}
document.addEventListener("scroll", onScroll, { passive: true });

2. Morphing

Why naive morphs break

Path interpolation walks the d command list of A and B in lockstep. If A has 4 cubic curves and B has 7, or A uses L where B uses C, the browser/animator either refuses or produces garbage. Fixes: match commands by hand, or use a library that resamples.

GSAP MorphSVG (best for arbitrary shapes)

import gsap from "gsap";
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
gsap.registerPlugin(MorphSVGPlugin);

// any shape -> path so it can morph
MorphSVGPlugin.convertToPath("#circ, #star");

gsap.to("#circ", {
  duration: 0.9, ease: "power2.inOut",
  morphSVG: { shape: "#star", shapeIndex: "auto", type: "rotational" },
});
  • shapeIndex rotates the starting point mapping to minimize twisting; "auto" lets GSAP pick, or pass a number to tune.
  • type: "rotational" interpolates angles/lengths instead of raw coordinates — usually smoother for organic morphs.
  • map: "size" | "position" | "complexity" changes how sub-paths in compound shapes are matched.

Flubber (standalone, framework-friendly)

import { interpolate, separate, combine } from "flubber";

// 1 -> 1 shape
const lerp = interpolate(pathA, pathB, { maxSegmentLength: 2 });

// splitting one shape into many (or vice versa)
const split = separate(oneShape, [shape1, shape2, shape3], { single: true });

Drive lerp(t) with any tween/requestAnimationFrame, or inside Framer Motion:

import { motion, useMotionValue, useTransform, animate } from "motion/react";
const progress = useMotionValue(0);
const d = useTransform(progress, [0, 1], [0, 1], { clamp: false });
const path = useTransform(d, (v) => interpolate(A, B)(v));
// animate(progress, 1, { duration: 0.6 }) then <motion.path d={path} />

Use Flubber when you can't add GSAP or need React-native value plumbing; use MorphSVG for the highest-quality mapping and convenience.

Hand-authored icon toggle (hamburger ↔ close) — no morph library

When you control both shapes, give them identical structure and animate transforms instead of d:

<svg viewBox="0 0 24 24" class="menu" aria-label="Menu">
  <line class="top" x1="3" y1="6"  x2="21" y2="6"/>
  <line class="mid" x1="3" y1="12" x2="21" y2="12"/>
  <line class="bot" x1="3" y1="18" x2="21" y2="18"/>
</svg>
.menu line { stroke:#111; stroke-width:2; transition: transform .25s ease, opacity .2s ease;
  transform-origin: center; transform-box: fill-box; }
.menu.open .top { transform: translateY(6px) rotate(45deg); }
.menu.open .mid { opacity: 0; }
.menu.open .bot { transform: translateY(-6px) rotate(-45deg); }

transform-box: fill-box makes transform-origin: center resolve to the element's own bounding box — essential for SVG rotation; without it the origin is the SVG viewport origin.

3. Motion along a path — details

GSAP MotionPath

gsap.to("#dot", {
  duration: 5, repeat: -1, ease: "none",
  motionPath: {
    path: "#route",
    align: "#route",          // coordinates relative to the path element
    alignOrigin: [0.5, 0.5],  // pin the object's center to the path
    autoRotate: true,         // or a number to add a constant offset angle
    start: 0, end: 1,         // animate a sub-segment, e.g. 0.2..0.8
  },
});

Convert a series of points into a smooth path: MotionPathPlugin.convertToPath(...), or pass motionPath: [{x:0,y:0},{x:100,y:50},...] and GSAP draws a curve through them.

CSS offset-path (declarative, modern)

.dot {
  offset-path: path("M10,80 C40,10 120,10 150,80");
  offset-rotate: auto;                 /* face direction of travel */
  animation: travel 3s linear infinite;
}
@keyframes travel { to { offset-distance: 100%; } }

Newer syntax also accepts offset-path: url(#route) referencing a <path> id and shapes like ray(). Good support in evergreen browsers; no JS needed.

SMIL animateMotion

Self-contained inside the SVG; use rotate="auto" and <mpath href="#id"/>. Best for portable icon assets where you don't want external CSS/JS. Avoid when you need scroll-sync or broad legacy support.

4. SMIL vs CSS vs JS — tradeoffs

Axis SMIL CSS JS (GSAP/WAAPI)
Bundle cost none (inline) none library (GSAP ~JS)
Works in <img> SVG yes yes (internal) no
Reach (legacy IE/Edge) no partial yes
Scroll/scrub sync hard hard easy
Morph mismatched paths no no yes (MorphSVG/Flubber)
Timeline orchestration limited limited full
Animate d/gradients/filters yes partial yes

Default: CSS for simple declarative effects, GSAP for anything coordinated/scrubbable/morphing, SMIL only for portable self-animating assets in evergreen targets.

5. SVGO config tuned for animation

Aggressive SVGO defaults will strip the very things you animate. Use a config like:

// svgo.config.js
module.exports = {
  multipass: true,
  plugins: [
    { name: "preset-default", params: { overrides: {
      removeViewBox: false,        // keep responsive scaling
      cleanupIds: false,           // DO NOT rename ids referenced by CSS/JS/SMIL/gradients
      mergePaths: false,           // keep sub-paths separate for staggered draw-on
      convertShapeToPath: false,   // keep <circle>/<rect> if SMIL/CSS targets them
      removeHiddenElems: false,    // keep elements you toggle visible via animation
      inlineStyles: false,         // preserve class hooks
    } } },
    { name: "removeDimensions" },  // drop width/height, keep viewBox -> fluid scaling
    { name: "prefixIds" },         // namespace ids if inlining many SVGs to avoid id collisions
  ],
};

If multiple inlined SVGs share gradient/filter ids, id collisions make the LAST definition win for everyone — prefixIds (or unique authoring) prevents this classic bug.

6. Reduced motion

@media (prefers-reduced-motion: reduce) {
  .path { animation: none; stroke-dashoffset: 0; } /* show final drawn state instantly */
}

For looping decorative SVG (spinners, ambient gradients), pause or hide them under reduced-motion; for functional icon toggles, keep the state change but drop the easing duration.


Pick the right method per shape and SVG draws, morphs, and travels smoothly. Built by iart.ai — the AI motion agent for editable, on-brand motion graphics.