# Svg Character Animator

> 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 cha…

- **Type:** Skill
- **Install:** `agentstack add skill-molauu-svg-character-animator-svg-character-animator`
- **Verified:** Pending review
- **Seller:** [molauu](https://agentstack.voostack.com/s/molauu)
- **Installs:** 0
- **Category:** [Web & Browser](https://agentstack.voostack.com/c/web-and-browser)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [molauu](https://github.com/molauu)
- **Source:** https://github.com/molauu/svg-character-animator

## Install

```sh
agentstack add skill-molauu-svg-character-animator-svg-character-animator
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# SVG Character Animator

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:

| Strategy | When | Implementation | Quality |
|---|---|---|---|
| **TRANSFORM** | Same 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. |
| **MORPH** | Different command structure | Hand `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 `` 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 x` → `L x cur_y` (using current y from path state)
- `V y` → `L cur_x y`
- Relative commands → absolute commands
- Implicit-repeat commands → explicit
- `` and `` → 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 ` 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 `` element inside a `` 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 `` 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: "px 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-out` — `easeInOutQuad`, 0.8s. Slow start, fast middle, slow end. The default "polished UI" feel.
- `snappy` — `easeOutCubic`, 0.4s — UI feedback, button toggles
- `bouncy` — `easeOutBack`, 0.8s — playful overshoot
- `dr

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [molauu](https://github.com/molauu)
- **Source:** [molauu/svg-character-animator](https://github.com/molauu/svg-character-animator)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** yes

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: flagged — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-molauu-svg-character-animator-svg-character-animator
- Seller: https://agentstack.voostack.com/s/molauu
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
