SVG Character Animator

SkillDev tools

Generate production-quality SVG character animations — state machines that morph between N SVG states (2–10) with per-state idle motion (breathing, blinking, swaying). Use whenever the user wants to animate a character or mascot between poses/emotions, morph icons/illustrations between forms, build interactive SVG state machines, or add ambient idle motion to vector graphics. Triggers include character/mascot animation, SVG path interpolation, character emotion transitions, icon toggles with animation, multi-state vector animations, or "make this SVG come alive." Also use when the user references Figma frames they want morphing between, even if they don't say "morph" explicitly, and whenever the user wants to port or export an SVG/Figma character animation to SwiftUI or iOS (the skill ships a design-in-browser, ship-to-iOS pipeline with a Swift exporter).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the SVG Character Animator skill

What this skill tells your AI

The instructions your AI receives, as published by molauu/svg-character-animator in SKILL.md and read by ahel’s review.

Generate runnable React components that animate between N SVG states (2–10) with a state-machine model, layered motion presets, and per-state idle animations.

This skill exists because the gap between a naive "morph" (jank, polygon artifacts, broken stroke caps, dead-feeling characters) and production-quality animation is huge — and the path to production quality is not "pick the right morph library." It's an architecture: per-path strategy detection, path normalization, transform-attribute interpolation, idle blending, and bottom-center pivot defaults. The skill encodes that architecture.

The most important insight

Most "morph" tasks aren't morph tasks. They're transform tasks dressed up. When you compare two SVG states of the same character, the eyes, nose, hands, and buttons usually have the same shape structure — they've just moved or rotated. Forcing those through a morph library (flubber, KUTE, Anime.js) destroys their bezier curves by polygon-sampling them.

The right approach is per-path strategy detection: analyze each shared path at init time and route it to one of two strategies — TRANSFORM if its command structure is consistent across states, MORPH otherwise. A real morph library is only needed when paths have genuinely different topology, which after path normalization is rare.

The two strategies

For each shared path id across states, the skill picks one of two strategies:

StrategyWhenImplementationQuality
TRANSFORMSame path command structure across states (after normalization)Linearly interpolate each bezier control point per frame. No library.Perfect — bezier curves preserved, stroke caps preserved, no polygon artifacts.
MORPHDifferent command structureHand d strings to flubber (MIT-licensed default).Acceptable. The library polygon-samples internally; quality depends on shape similarity and is hidden by speed for quick easings.

This is the heart of the skill. Everything else is in service of getting more paths into the TRANSFORM bucket.

On fill differences across states. Fill is not a strategy axis. Earlier versions defined two "crossfade" strategies that stacked one copy of the path per state and tweened opacity to swap fills mid-morph; those were removed (N stacked copies per id, N parallel tweens, broke the render-once invariant). The current behavior splits by paint type:

  • Flat colors (hex / white / black) lerp smoothly with the morph — runFillTween interpolates RGB per frame with the transition's easing. This matters when the palette IS the character's identity (e.g. five differently-colored Figma characters morphing into each other): a color pop at morph-end reads as a glitch. The Swift export mirrors this with a per-state fillColor(for:in:) lookup driven by an animated fillState.
  • url() paint servers (gradients) snap at completion via setAttribute("fill", toFill). At sub-second durations a clean snap is usually indistinguishable from a crossfade. If the snap reads as too abrupt, the right fix is upstream: normalize the source SVGs to share a single paint server (swap gradient stops rather than paint servers). See references/strategy-detection.md.

Unify per-state Figma gradient ids (the most common fill-snap trap). Separate Figma frame exports mint a fresh gradient id set per state (paint0_radial_1532_818 vs _779 vs _778) even when the gradients are visually identical — same stops, centers a few px apart because they ride the part. Left as-is, EVERY shared path swaps its paint server on EVERY transition and the whole character does a fill pop at morph end (one real character did this on all 12 parts). Fix at states-file authoring time: define ONE canonical gradient per part with a generic id (grad-head), position it at the centroid of the per-state gradient centers, reference it from every state, and put the shared <defs> block on one state only (all states' defs render regardless). Only keep per-state gradients when the stops genuinely differ — then the snap is a real design decision.

Path normalization (the trick that gets paths into TRANSFORM)

SVG editors emit equivalent shapes with different command syntax. Figma sometimes exports L x y (line-to), sometimes H x (horizontal line-to). Both describe the same geometric segment. Without normalization, a naive parser sees MCLCCLCZ and MCHCCHCZ as different structures and routes the path to MORPH unnecessarily.

Always normalize before fingerprinting. Normalization rules:

  • H xL x cur_y (using current y from path state)
  • V yL cur_x y
  • Relative commands → absolute commands
  • Implicit-repeat commands → explicit
  • <circle> and <ellipse> → cubic-bezier path data via the standard k=0.5523 approximation
  • Strip whitespace differences, normalize decimal precision

After normalization, structurally-identical-but-syntactically-different paths fingerprint the same and route to TRANSFORM. In the snowman test case, normalization moves all 9 shared paths into TRANSFORM — no morph library fires at all.

Where it lives: the preview engine's parsePath normalizes H/V → L at parse time, which serves both consumers at once: fingerprints treat L/H/V as one structure, and the TRANSFORM interpolator re-emits every segment as <cmd> x y — emitting a raw H with an x AND a y would parse as two horizontal line-tos and visibly crack the shape mid-morph. If you write a new parser, normalize inside it, not as a separate pre-pass someone can forget.

Transform attribute interpolation

The SVG transform attribute (e.g., transform="rotate(13 cx cy)") is separate from the d attribute. If state A has a rotation and state B doesn't, naive d-only interpolation leaves the rotation stuck on state A's value mid-morph, then snaps to none at completion.

Always parse and interpolate the transform attribute alongside d. Parse rotate(deg cx cy), translate(x y), scale(sx sy), matrix(...) into a normalized struct, linearly interpolate each field, re-emit as a transform string. See references/transform-interpolation.md for the exact parser.

…but prefer BAKING the transform into the path data when you author the states file. Apply the matrix to every control point once, offline, and drop the attribute. Three concrete wins: (1) members of a _group all interpolate through the same point-lerp path — matrix-lerp and point-lerp trajectories differ mid-flight, so a matrix-transformed white around a baked pupil drifts apart; (2) matrix component lerp distorts under overshoot easings (eased t > 1 extrapolates the components into a shear — "the eye whites move to the wrong position"), while the rotation-aware point lerp extrapolates rigidly; (3) CSS style.transform OVERRIDES the SVG transform attribute, so per-element idles (blink, look-around) silently teleport any element that still carries one — baked paths are idle-safe. Keep attribute interpolation for cases you can't bake (a rotation that genuinely differs per state on an otherwise identical shape, e.g. the snowman's head tilt).

Multi-subpath splitting

Many stroke paths contain multiple M commands — they're really N separate strokes in one element. Hands are the typical case: M5,10 L10,10 M5,15 L10,15 M... is four separate finger-lines, not a continuous path.

No JS morph library handles multi-subpath paths correctly. They all treat the first subpath as the whole shape and ignore the rest, or worse, try to morph the whole string as one closed shape and produce gibberish.

Solution: detect multi-subpath paths at render time (split on [Mm]), render each subpath as a separate <path> element inside a <g> wrapper, morph each subpath independently. The split is purely a render-layer concern; the source data still has one id per anatomical part.

The render/animation separation

A React-specific pitfall: re-rendering the SVG path elements on state change causes flashes where the previous state's element briefly appears before the morph starts. React's reconciler doesn't know our imperative setAttribute('d', ...) calls are mid-flight; it sees the state change and updates the rendered d to match.

Solution: render shared paths once at initial mount, never replace them on state change. After mount, all d mutations happen via setAttribute on refs. React only re-renders orphan layers (decorations that fade in/out per state). Internal state changes use useRef + forceRender rather than useState for the current state, so React never tries to re-render the morphing elements.

Orphan paths (decorations not present in all states)

Real artwork has decorative elements that exist in only some states: an ice cube background in one, a bird companion in another, flowers in a third. These are orphans, not bugs.

Don't try to morph them — there's nothing on the other side. Instead:

  1. Render orphans from all states upfront, with opacity controlling visibility (opacity: stateName === currentState ? 1 : 0).
  2. On transition: fade out departing orphans, fade in arriving orphans. Departures run in the first half of the morph; arrivals run in the second half, with a slight overshoot scale (0.9 → 1.0 with back.out) for a subtle "pop" feel.
  3. Render decorative orphan content verbatim from source SVGs — don't try to reconstruct or simplify. If the user supplies SVG files, extract the relevant <g> blocks via regex/parser and inline them. Approximating decorations always looks worse than the original.
  4. Prefer structured _orphan: true path entries over the verbatim orphanSVG block when the decoration is a handful of simple shapes. Structured orphans show up in the preview's path editor (selectable, role/method editable) and can carry per-element flags (_idleOwned, data-twinkle, filter); orphanSVG contents are invisible to the editor. Keep orphanSVG for genuinely complex verbatim groups (nested filters, many elements) where re-authoring as entries would risk fidelity.

Orphan interrupt safety (fast state switching)

When the user clicks a new state before the current transition completes, orphan animations must be cleanly cancelled:

  1. popIn must return a cancellable handle ({ cancel }) and be tracked in activeMorphsRef alongside other tweens. Fire-and-forget popIn calls leave zombie rAF loops that set opacity back to 1 on orphans that should be hidden. The cancel handler must reset opacity to 0 (not just transform) so half-faded orphans don't linger.
  2. Force-reset must NOT snap fromState orphans back to opacity 1. During fast switching, currentStateRef never updates (transitions never complete), so every new transition has the same fromState. Resetting its orphans to opacity 1 each time causes a visible flash. Instead: clear transform/transformOrigin on all orphans, set non-fromState orphans to opacity 0, but leave fromState orphan opacity as-is. The fade-out tween handles them from their current value.
  3. Fade-out tweens must read current opacity, not hardcode from: 1. A half-faded orphan (at 0.3 from a cancelled transition) would flash to 1 if the tween resets it. Read parseFloat(t.style.opacity) and scale the fade duration proportionally (durationMs * 0.4 * curOp) so partially-faded orphans finish faster.
  4. tweenStyle must clear its setTimeout on cancel. Without this, a delayed tween's begin callback fires after cancellation, potentially re-entering the animation loop.
  5. popIn must convert SVG transform to valid CSS before composing. SVG and CSS transform syntax differ — passing raw SVG into style.transform silently fails:
    • SVG rotate(angle cx cy) → parse the angle and center, set CSS transform-origin: ${cx}px ${cy}px, use CSS rotate(${angle}deg). Do NOT use the translate(cx,cy) rotate(a) translate(-cx,-cy) expansion — it conflicts with transform-origin and places the element at the wrong position.
    • SVG rotate(angle) (no center) → CSS rotate(${angle}deg), keep default transform-origin.
    • The pop scale/rotate is then composed after the SVG rotation: transform: rotate(Xdeg) scale(S) rotate(popDeg).
    • On animation complete: restore the SVG transform attribute, clear style.transform and style.transformOrigin.
    • Why this matters: orphan elements from Figma exports often carry SVG transform="rotate(...)" for tilted decorations (fur, feathers, petals). Without proper conversion the element pops in at the wrong angle/position, then snaps to correct placement when CSS clears — a visible jump.

The two-layer animation model

Every SVG character has two layers of life:

Morph layer — the transition between states. Driven by easing curves, duration, and stagger across sub-paths. The "deliberate" motion.

Idle layer — the ambient motion within a state. Breathing, swaying, blinking. The "alive" motion.

A morph without idle feels robotic — character freezes mid-transition. An idle without morphs is a screensaver. The two must blend, not compete.

Idle×morph blend (the calibration that matters):

  • During morph, ramp idle amplitude down to 0.1 (not 0.3, not zero) over 40% of morph duration (not 20%) using sine.inOut.
  • After morph, ramp back to 1.0 over 40% of duration, again sine.inOut.
  • These numbers were learned through iteration. 20%/0.3/cubic out reads as "competing motion." 40%/0.1/sine reads as "calm pause then resume."

Bottom-center pivot principle: all rotation and scale idle animations default to transform-origin: 50% 100% (bottom-center). Characters stand on a ground plane. Rotating around their feet feels right. Override only when the character is meant to be floating (then center-center) or hanging from above (top-center).

Face features travel together in position-type idles. When a gaze/glance idle (look-around, or any translate-driven part) moves the face, target EVERY face feature — eyes, mouth, brows — in the same selectorAll, so they share the exact same x/y offsets each frame. Eyes darting while the mouth stays planted reads as the face sliding off the head (the mouse critter's original bug). Two nuances:

  • This applies to position changes only (translate x/y). Per-part animations stay per-part: blink squishes only the eyes, a mouth talk-cycle only the mouth — those compose ON TOP of the shared face offset (the engine's blink appends to whatever transform an earlier part set, so order the face-move part before it).
  • Exception: pupils moving inside static eye whites. That's gaze without head movement — the whites and mouth correctly stay put; only pupil ids go in the selector.

Off-center artwork needs an explicit anchor. 50% 100% is the bottom-center of the CANVAS, not the character — a big tail or side decoration pushes the viewBox wide and the default pivot lands beside the feet, so sway reads as lateral sliding (e.g. a character whose feet sit at x≈136 in a 336-wide viewBox). Set an explicit origin: "<x>px <y>px" on whole-body idle parts at the character's actual feet. The preview's "Idle anchor" card edits this live (presets + click-to-place on canvas, persisted as OVERRIDES.idleAnchor); a user-set anchor overrides authored part origins.

The layered preset system

Easing presets — one per genuinely distinct feel (smooth and gentle were removed as near-duplicates of ease-in-out; the curve/duration editors cover any in-between):

  • linear — constant velocity, 0.8s. Mechanical, robotic. Almost never right for character motion; useful for marquees, progress bars, and chained transitions where any easing would interrupt the chain.
  • ease-in-outeaseInOutQuad, 0.8s. Slow start, fast middle, slow end. The default "polished UI" feel.
  • snappyeaseOutCubic, 0.4s — UI feedback, button toggles
  • bouncyeaseOutBack, 0.8s — playful overshoot
  • dreamyeaseInOutSine, 2.5s — ambient, slow

When in doubt, default to ease-in-out. Mention what you picked.

Custom easing curves — the preview's Easing card includes an editable cubic-bezier curve (CSS timing-function semantics: P0=(0,0), P3=(1,1), draggable P1/P2, y-overshoot allowed for back-out feels). Selecting a preset shows its bezier equivalent; dragging a handle switches to a custom curve that overrides the named ease everywhere (transitions and exports). The solver is cubicBezierEase(x1,y1,x2,y2) in the engine; preset equivalents live in EASE_BEZIER.

Scenario/stagger presets were removed. All shared paths morph in unison (the old icon-toggle behaviour is the only mode). The stagger layer (page-transition, character-emotion-shift, ambient-loop) and its _syncGroup companion field added a preset axis that rarely earned its complexity; per-path methods (transform/morph/crossfade/scale) are the expressive axis now. _syncGroup fields in older states files are ignored harmlessly.

The aesthetic preset layer was removed. It was a thin modifier on easing that the curve/duration editors express directly: playful (ease override to easeOutBack) ≡ the bouncy preset or dragging the curve into overshoot; vintage (duration × 1.5) ≡ editing the duration; floating was an unimplemented no-op. One preset axis (easing, with an editable curve) plus a duration editor covers the whole space with less to explain.

When the user doesn't specify, default to ease-in-out easing and mention what you defaulted to.

_group — one rigid frame for nested parts. Parts that must stay visually nested — eye white + pupil + highlight, arm + sleeve, hand + held object — must travel the SAME trajectory mid-morph. Each TRANSFORM path normally estimates its own rigid rotation (Kabsch), and two different estimates diverge mid-flight: the pupil escapes the white even though both land correctly. Set _group: "eyeL" on all members and the engine estimates ONE rotation + centroid path from the union of the group's points, then interpolates every member in that shared frame (endpoints stay exact regardless). Corollary: members of a group should also share an interpolation mode — bake attribute transforms into path data rather than mixing matrix-lerped and point-lerped members (see below).

Idle primitives — each can be used standalone ({ kind: "sway", ... }) or composed inside a compound (see below):

  • sway — gentle ±2° rotation, bottom-center pivot
  • breathe — uniform scale 1.0 → 1.02 (half-wave, reads as a pulse)
  • breathe-y — vertical-only scale, full sine wave (symmetric, reads as steady breathing). Prefer this over breathe for upright characters.
  • bob — vertical translation ±3px
  • shake — quick ±1° wobble
  • twinkle — opacity flicker on [data-twinkle="true"] children
  • sway-twinkle — combo (sway whole character + twinkle decorations)
  • drift — slow random-walk translation
  • waggle-sequence — choreographed body sweep: ramp to −A° → brief hold → ramp through to +A° → brief hold → ramp back. Sine-eased ramps, tiny holds so the body sweeps continuously rather than slamming. Configure with amplitude, cycleDuration, restFraction (how much of the cycle is centered rest).
  • rotate-around-point — rotate a child element (selected via selector: "#path-<id>") around an absolute SVG-user-space pivot: [x, y]. Use for limb motion where the pivot isn't the character's bottom-center. Supports optional gating (gateCycle, gateRange, gateFade) to silence the motion during a sibling part's active phase — see "Gating" in references/idle-animation-recipes.md.
  • bloom — scale-cycle a selection of decorative elements with randomized phases. Owns scale; the morph engine's entrance/exit fades own opacity. Use for "blooming flowers" or "pulsing sparkles" that need to play nicely with state transitions.

Composition — wrap any subset in compound to run them simultaneously:

idle: {
  kind: "compound",
  parts: [
    { kind: "breathe-y", duration: 3.5, amplitude: 0.025 },
    { kind: "rotate-around-point", selector: "#path-handl",
      pivot: [105.741, 210.402], amplitude: 6, duration: 1.0 },
    { kind: "rotate-around-point", selector: "#path-handr",
      pivot: [194, 215.475], amplitude: -6, duration: 1.0 }
  ]
}

This is how the snowman gets a breathing torso with independently-pivoted hand motion in state-5. See references/idle-animation-recipes.md for the full case study.

The user can also specify custom prompt nuance per state ("eyes blink every 3-5s", "tail wags occasionally") — translate to GSAP-style tweens targeting specific ids; see references/idle-animation-recipes.md.

State machine

Every state can transition to every other state. The component exposes:

  • A controlled currentState prop
  • An imperative transitionTo(stateName) method via useImperativeHandle
  • Optional autoSequence: { mode: 'linear' | 'random' | 'weighted' | 'shuffle', interval: ms }

Interrupting in-flight morphs: kill the active timeline, start the new morph from current rendered paths (whatever d they currently have). The visual handoff is smooth because we're already in path-data space.

Workflow when invoked

  1. Confirm inputs. Are SVGs pasted or referenced via Figma MCP? How many states? Are sub-path ids matched across states? If anything's missing or unclear, ask in one consolidated question, don't ping-pong.
  2. Parse each state. Extract the elements with ids, normalize their path data, capture defs (gradients).
  3. Compute shared vs orphan ids. Anything not in all states is an orphan.
  4. Compute strategy per shared id. Run normalize → fingerprint → compare across states → assign TRANSFORM (same structure) or MORPH (different structure).
  5. Generate the component. Single React file, flubber as the MIT-licensed morph library, runnable demo wrapper with state buttons + preset selectors + background color picker.
  6. Always render all states' defs. Gradients referenced across states must be in the DOM regardless of which state is active.
  7. Verify and present. Run the file-output workflow and show the demo.

Workflow when iterating

After v1, expect the user to find issues. Common ones and the right diagnosis:

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
22
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
svg-character-animator
Source
github.com/molauu/svg-character-animator