AgentStack
SKILL unreviewed MIT Self-run

Svg Character Animator

skill-molauu-svg-character-animator-svg-character-animator · by molauu

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…

No reviews yet
0 installs
0 views
view→install

Install

$ agentstack add skill-molauu-svg-character-animator-svg-character-animator

Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.

Security review

⚠ Flagged

1 finding(s); flagged for manual review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures
  • high Dangerous shell/eval execution.

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution Used

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Svg Character Animator? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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 xL x cur_y (using current y from path state)
  • V yL 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-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
  • `dr

Source & license

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

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

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.