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
⚠ Flagged1 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.
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 —runFillTweeninterpolates 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-statefillColor(for:in:)lookup driven by an animatedfillState. url()paint servers (gradients) snap at completion viasetAttribute("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). Seereferences/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:
- Render orphans from all states upfront, with opacity controlling visibility (
opacity: stateName === currentState ? 1 : 0). - 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. - 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.
- Prefer structured
_orphan: truepath entries over the verbatimorphanSVGblock 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);orphanSVGcontents are invisible to the editor. KeeporphanSVGfor 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:
popInmust return a cancellable handle ({ cancel }) and be tracked inactiveMorphsRefalongside other tweens. Fire-and-forgetpopIncalls 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.- Force-reset must NOT snap fromState orphans back to opacity 1. During fast switching,
currentStateRefnever updates (transitions never complete), so every new transition has the samefromState. Resetting its orphans to opacity 1 each time causes a visible flash. Instead: cleartransform/transformOriginon 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. - 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. ReadparseFloat(t.style.opacity)and scale the fade duration proportionally (durationMs * 0.4 * curOp) so partially-faded orphans finish faster. tweenStylemust clear itssetTimeouton cancel. Without this, a delayed tween'sbegincallback fires after cancellation, potentially re-entering the animation loop.popInmust convert SVGtransformto valid CSS before composing. SVG and CSS transform syntax differ — passing raw SVG intostyle.transformsilently fails:
- SVG
rotate(angle cx cy)→ parse the angle and center, set CSStransform-origin: ${cx}px ${cy}px, use CSSrotate(${angle}deg). Do NOT use thetranslate(cx,cy) rotate(a) translate(-cx,-cy)expansion — it conflicts withtransform-originand places the element at the wrong position. - SVG
rotate(angle)(no center) → CSSrotate(${angle}deg), keep defaulttransform-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
transformattribute, clearstyle.transformandstyle.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:
blinksquishes only the eyes, a mouth talk-cycle only the mouth — those compose ON TOP of the shared face offset (the engine'sblinkappends 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 togglesbouncy—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
- Source: molauu/svg-character-animator
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.