| name | svg-character-animator |
| description | 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). |
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 <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 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
<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:
- 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
<g> blocks via regex/parser and inline them. Approximating decorations always looks worse than the original.
- 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:
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.
- 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.
- 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.
tweenStyle must clear its setTimeout on cancel. Without this, a delayed tween's begin callback fires after cancellation, potentially re-entering the animation loop.
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-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
dreamy — easeInOutSine, 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
- 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.
- Parse each state. Extract the elements with ids, normalize their path data, capture defs (gradients).
- Compute shared vs orphan ids. Anything not in all states is an orphan.
- Compute strategy per shared id. Run normalize → fingerprint → compare across states → assign TRANSFORM (same structure) or MORPH (different structure).
- Generate the component. Single React file, flubber as the MIT-licensed morph library, runnable demo wrapper with state buttons + preset selectors + background color picker.
- Always render all states' defs. Gradients referenced across states must be in the DOM regardless of which state is active.
- 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:
- "It only changes at the end, no morph in between" — your morph engine wrapper is broken. Verify the actual library is loaded and the API call is correct.
- "Stroke tips become flat during morph" — the path is a stroke with
M ... M ... and the library is treating it as one closed shape. Split subpaths.
- "Eyes/circles become triangles during morph" — the library is polygon-sampling at a coarse resolution. For small shapes, the right answer is usually to route them to TRANSFORM (their structure matches across states); if you truly need MORPH, set
maxSegmentLength: 0.5 (flubber) or precision: 0.5 (Anime.js).
- "Body fill snaps abruptly at end of morph" — the snap is intentional; crossfade was removed (see "On fill differences across states"). If the user really wants smooth, the fix is upstream: normalize source SVGs to share one fill, or swap gradient stops rather than swap paint servers. As a runtime workaround, kick off a short opacity fade on a second copy with the destination fill at midpoint — but resist this; it usually means the source SVGs need attention.
- "Idle stops and restarts visibly between morphs" — your idle timeline is being killed and recreated. Use one persistent idle animator whose amplitude is tweened, never killed. Reset transforms with
gsap.set(svgRef, { rotation: 0, scale: 1, x: 0, y: 0 }) before re-running.
- "Whole image rotates around center for shake/breathe" — you forgot the bottom-center pivot default. Apply
transform-origin: 50% 100% to all rotation/scale idles by default.
- "Previous state flashes before new state renders" — React is replacing the SVG path elements on state change. Render shared paths once, mutate via refs only.
- "The hand and nose are only rotating/moving, not morphing — can we detect that?" — Yes, that's exactly what strategy detection does. If they're in MORPH but should be TRANSFORM, your fingerprint comparison is failing — usually because normalization isn't catching H/V or relative commands.
On morph library choice
| Library | License | Status | Multi-subpath | Native primitives | Recommendation |
|---|
| flubber | MIT | Unmaintained since 2019 | First subpath only | Path only | Use as the default for MORPH strategy. Solid for what it does. |
| Anime.js v4 | MIT (free for commercial) | Active | First subpath only | Path/circle/rect/ellipse/polygon | Reasonable alternative if user wants a more general-purpose animation engine. svg.morphTo() API. |
| KUTE.js | MIT | Active | First subpath only | Path only | Reasonable but no major advantage over flubber for our needs. |
Default to flubber. The TRANSFORM-first architecture means the morph library is rarely used anyway, so the difference in library quality matters less than you'd expect. The cases where MORPH genuinely fires are usually large shapes (body, head) where polygon sampling reads acceptably.
Demo wrapper requirements
Every generated component must include a runnable demo wrapper with:
- State buttons — one per state, click to transition
- Easing (preset dropdown + editable bezier curve), duration editor, idle-anchor card — change any and the next transition/idle uses the new config (via
presetRef.current pattern)
- Background color picker — color input + small swatch palette (neutral / white / midnight / warm / mint / lavender / blush / ink). Critical for evaluating how the character reads against different surfaces.
- Strategy panel — shows which strategy each shared path was assigned. Helps the user (and us) understand what's happening and debug.
Reference files
Load on demand:
references/path-normalization.md — exact parser for d strings, H/V→L logic, decimal precision rules
references/strategy-detection.md — the two-strategy decision tree with examples, plus the rationale for removing the crossfade variants
references/transform-interpolation.md — parsing transform="..." and lerping
references/morph-library-comparison.md — flubber vs Anime.js vs KUTE vs Motion: capabilities, licenses, when to use which
references/motion-design-basics.md — 12 animation principles applied to morphs
references/idle-animation-recipes.md — per-preset implementation, custom prompt translation, idle×morph blending
references/state-machine-patterns.md — N×N transitions, auto-sequencing, transition guards
references/ios-porting.md — SwiftUI Animatable shape, SVG → CGPath conversion, the offline pre-matching pipeline
What goes into MorphAnimator.jsx vs the references
MorphAnimator.jsx contains: the two-strategy decision tree (executed at init), the path/transform parsers, the TRANSFORM and MORPH tween runners, the idle animator, the orphan render logic, and the demo wrapper. Inline comments mark each section so the user can find what to tweak.
References contain: the why and the wider design space. Load when the user asks "why does this work" or "can I do X instead."
Anti-patterns to avoid
- Don't recommend "just use a better morph library." The library is rarely the issue. Strategy detection is.
- Don't hand-draw simplified orphan decorations when you have access to the source SVG. Extract verbatim.
- Don't kill and recreate the idle timeline on every state change. Tween its amplitude.
- Don't render the demo with the same morph library as production, then claim "this is what the production version looks like" — if they're different, say so explicitly.
- Don't pick a transform-origin without thinking. Default to bottom-center for characters. Center-center only for floating objects.
- Don't ship the first version without a background color picker in the demo. Half the perceived quality issues turn out to be "it doesn't read well on white but looks great on midnight blue."
Preview template (zero-build browser harness)
For quickly testing states without a bundler, the skill ships a self-contained preview shell at templates/preview/. Useful when the user pastes raw SVG states and wants to see the morph behavior before wiring it into their app.
The shell separates concerns so a new test is a one-file change:
templates/preview/preview.html — the chrome: header + stat strip, a canvas that floats directly on the page, and a scrollable edit panel (paths editor / easing preset + bezier curve / duration / idle presets + anchor / background / exports). The edit panel has no frame — a draggable divider separates it from the canvas and resizes it (340–720px, persisted). The exported web player shares the same look.
templates/preview/engine.jsx — pure JS engine: preset library, path parser, strategy detector, transform interpolator, tween runners (runTransformTween, runMorphTween, runRigidTween, runCrossfadeTween, runScaleTween), IdleAnimator. Exposes everything on window.MorphEngine.
templates/preview/app.jsx — React component (MorphAnimator) + the App shell (and a minimal PlayerApp used by the exported web player). Mounts to #root.
templates/preview/exporters.js — plain-JS export utilities on window.PreviewExporters: PNG-sequence recorder (store-only zip writer, no dependency) and the standalone web-player HTML bundler.
templates/preview/states.example.js — minimal circle→square example demonstrating the data contract.
Contract for a new test: author a <name>.states.js setting window.STATES_DATA = { ... } (and optionally window.PREVIEW_CONFIG for title/viewBox/size). Copy preview.html, change the first <script src="..."> to your states file. Engine, app, and exporters remain unchanged.
Optional knobs the engine respects (set before engine.jsx loads):
window.PREVIEW_CONFIG = { title, subtitle, viewBox, size: { width, height } }
window.STRATEGY_OVERRIDES = { body: "MORPH" } — force a strategy for a path id
window.STRATEGY_PATH_PATCH = { body: { fill: "none", stroke: "#c8d0d8", strokeWidth: "1" } } — apply attribute patches to all states' copy of a path. Useful for debug-stripping a fill so the raw silhouette morph is visible.
window.PATH_OVERRIDES = { roles: { id: "orphan"|"shared" }, methods: { id: "TRANSFORM"|"MORPH"|"CROSSFADE"|"SCALE" }, durationMs } — the path editor's override contract. In the editor it's persisted to localStorage (keyed by PREVIEW_CONFIG.title); the exported web player embeds it verbatim.
Standalone studio (no server needed)
node scripts/build-standalone.js bundles the preview into ONE double-clickable HTML (default output: <repo root>/animator-studio-en.html; --lang cn emits the Chinese-UI animator-studio-cn.html). There is no separate upload page: first open shows the normal preview layout with every panel empty and a "drop your animation file here / or click to choose" chooser inside the canvas window (any *.states.js file — same contract as states.example.js, may also set PREVIEW_CONFIG / OVERLAYS_DATA / STRATEGY_OVERRIDES). On load the full editor boots with every feature, including all three exports. Loaded files accumulate in a localStorage library; a small bar pinned bottom-left below the preview column holds a file switcher plus "+ add file", so multiple characters can be loaded and swapped (switching/adding reloads the page — the engine is init-once by design).
How it works: the served preview needs a server only because Babel fetches engine.jsx/app.jsx over XHR (blocked on file://). The studio embeds the raw sources as strings (window.__STUDIO_SOURCES) and boots them on demand — states code runs first, then eval(engine), then Babel.transform(app) + eval. exporters.js prefers __STUDIO_SOURCES over fetching, so the web-player export works offline-from-disk too; Blob downloads, localStorage overrides, and canvas rasterization all work on file:// natively (the studio makes zero same-origin requests — verified). The active file auto-resumes on reload, which is what makes the path editor's "apply role changes (reloads page)" flow seamless; files added while a character is live are validated against a scratch window first so a bad file can't wedge the reload loop.
Two caveats: the CDN scripts (React / flubber / Babel) still need internet, and the studio is a build artifact — edit templates/preview/* or scripts/export-swift.js, then re-run the build (never hand-edit the studio HTML files). Raw sources inlined into <script> tags are sanitized against literal script-close sequences (a <\/script in a comment once broke the whole bundle).
In-browser editing (paths panel + duration)
The preview is an editor, not just a viewer:
- Click any part on canvas (or a row in the "Paths & transitions" panel) to select and highlight it.
- Role select per path —
character (shared, morphs between states) vs orphan (fades in/out). Roles feed shared-id computation, which runs once at engine init (render-once architecture), so role changes persist to localStorage and apply via the panel's "Apply (reloads page)" button. Paths not present in every state are locked to orphan.
- Method select per shared path —
auto (detected) / smooth morph (TRANSFORM) / sampled morph (MORPH) / crossfade / move & resize (SCALE). Applies live to the next transition. The UI deliberately does NOT say "transform"/"morph": both of those deform contours (exact vertex lerp vs polygon-sampled library) and users read "transform" as rigid-only; the two genuinely non-deforming methods are crossfade (fade out → swap at midpoint → fade in) and move & resize (rigid from-bbox → to-bbox mapping). All work on multi-subpath ids too. Forcing smooth morph onto structurally-mismatched paths falls back to the sampled morph (control points can't pair — same guard as the Swift exporter).
- Duration — selecting an easing preset sets its default duration; editing the slider/number switches to a custom value that wins everywhere (transitions, all three exports) and persists.
- Idle anchor — preset pivots (bottom-center / center / top-center) or "pick on canvas" to drop the whole-body idle pivot at an exact point (crosshair marker shows it). Applies live, persists, and wins over authored per-part origins.
- Idle presets — swap every state's authored idle for a quick whole-body preset (none / breathe / breathe-y / sway / bob / shake / waggle / float) while tuning; "authored" restores the states-file idles. Applies live, persists, exports.
- X-ray toggle (above the canvas) — dims the fill render and overlays live wireframes: path outlines, bezier handle arms, anchor dots (cyan) and control-point dots (pink), sampled from the live
d attributes each frame so it works mid-morph. Rendered inside the character's svg via getCTM, so rigid wrappers and idle transforms are reflected.
- Easing curve — the Easing card renders the active preset's cubic-bezier equivalent; dragging either handle switches to a custom curve (persisted, exported). Overshoot (y outside 0–1) is supported.
All editors live in the right-hand panel (sticky, independently scrollable); the canvas card hugs its content so the panel gets the width.
Exports from the preview
Three export panels, all capturing the current tuned settings (duration, curve, methods, idle preset + anchor, particle sliders). The canvas background color is preview-only — Swift and the web player render transparent (style the host page/view); the PNG sequence includes it only when "transparent background" is unchecked:
- PNG sequence — records the live canvas through one transition (pick target state, fps 12–60, 1–3× scale, optional transparent background), rasterizes each frame, downloads a
.zip of frame_NNNN.png. Implemented with an in-repo store-only zip writer — PNGs are already compressed, so deflate would buy nothing and a zip library would be a dependency for nothing. Recording is blocked while X-ray is on (the wireframe layer lives inside the svg and would be serialized into every frame — the button says "Turn off X-ray to record").
- Web animation — one standalone
.html player: live states + engine + a minimal player UI (state buttons, autoplay cycle, play/pause) inlined; React/flubber loaded from CDN. A live-engine player was chosen deliberately over keyframe-baked export formats: strategy detection, rigid-body tweens, and idle blending can't be represented as pre-baked keyframed vertices without huge files and loss of interactivity. The single file opens directly or embeds via <iframe>.
- SwiftUI — see "SwiftUI export" below.
This is distinct from templates/MorphAnimator.jsx + templates/App.jsx, which are the production ES-module components the agent should generate when the user wants real shippable code. The preview shell is the testbed; the templates are the product.
SwiftUI export (scripts/export-swift.js)
The preview includes an "Export to SwiftUI" panel that emits a self-contained .swift file from the current states + tuned settings. The same conversion is available as a Node CLI for batch builds: node scripts/export-swift.js <name>.states.js out.swift --type <TypeName> --enum <StateEnum> [--type-prefix <Prefix>].
The script is designed to handle any well-formed states file without manual editing of the output. What it covers automatically:
-
Multi-animator namespacing — the emitted file declares generic module-internal types (MorphShape, MorphPathSegment, IdlePart, IdleTransform, ElementIdle, ParticleSpec, MorphRotation, RigidBody, …) that would collide if two exported animators live in one app/module. Pass --type-prefix Snowman (CLI) or set the prefix field in the export panel (defaults to the character name) to prefix every such type — declaration and all references — e.g. MorphShape → SnowmanMorphShape. It's auto-detected from the generated source (so it also covers types only some characters emit, and any future ones), skips private/fileprivate types (which never collide) and your already-unique --type/--enum. The public surface you reference (SnowmanView, SnowmanState) is untouched. Verified: four exported animators, each prefixed, compile together in one module with zero errors.
-
State machine of any size — N states, N×N transitions, public/Binding API.
-
Strategy detection — fingerprints each shared path's command structure across states; assigns TRANSFORM (control-point lerp via MorphShape) or MORPH (polygon-sampled at 80 vertices via MorphPolygonShape). TRANSFORM paths lerp with a rotation-aware (2D Kabsch) interpolation — it factors out the rigid rotation between poses so a part that rotates between states (e.g. a wing swinging up) follows the arc instead of collapsing toward its centroid at the midpoint, and degrades to plain linear lerp when there's no rotation (matches the JS engine's interpolateStructuredPaths).
-
Multi-subpath rigid-body — auto-detects paths with multiple M commands (e.g. snowman hands as 4 finger-strokes). Each renders the canonical state's geometry stroked per subpath; transitions animate translation+rotation around the wrist anchor via RigidBody: AnimatableModifier. Stroke caps stay welded.
-
Per-path transform attributes — parses rotate(deg cx cy), translate, scale, matrix(a b c d e f) and bakes them into orphan geometry. Per-state rotation differences on shared paths (e.g. snowman head tilt in state-1) animate via MorphRotation: AnimatableModifier.
-
Idle animation — emitted as two layers. Whole-body root parts go through evaluateIdle: sway, breathe, breathe-y, bob, nod, shake, waggle-sequence, rotate-around-point (with optional gating: gateCycle / gateRange / gateFade). Per-element parts (those with selectorAll or selector) go through idleForElement + the ElementIdleMod modifier, which returns a FULL transform per shared-path or orphan id — offset + rotation + scale + an appended blink squish + opacity — not just a rotation. Per-element kinds: sway / nod / rotate-around-point (incl. counterSway, phase), blink (vertical squish about the element's own center), spin (continuous own-center rotation, incl. alternate), drip (welling tear cycle), and pop-loop (staggered appear→hold→disappear with scaleRange / rotateRange / originSelf). Element origins (own-center, pivot, transformOrigin, "50%") bake to canvas-relative UnitPoints because the source uses transform-box: view-box. Any compound composition is supported, and every kind is numerically verified against the JS engine.
-
Orphan back/front layering — reads each orphan's _layer field. Back-layer orphans (_layer: "back") render behind shared paths; default-front orphans render in front. Per-state orphan groups crossfade via a displayedOrphanState driven inside withAnimation at transition start. Each state's orphan layer flattens via .compositingGroup() before fading so overlapping transparent shapes don't double-blend. Every orphan is canvas-framed and wrapped with ElementIdleMod, so idle-driven orphans (spinning swirls, the falling tear, popping notes) animate in place; static decorations get an identity transform.
-
Looping orphan bursts — a state-level orphanPopLoop config (e.g. a super-happy state's popping stars) compiles to a continuous per-star phase via applyOrphanPopLoop: each id pops in with a back-out overshoot (staggered), holds, then drifts outward along its radial from canvas center while shrinking and fading, then rests and repeats. _idleOwned orphans and orphanEnter: "none" need no special handling — the idle opacity (ramped by idleAmplitude) owns their visibility, so they fade in with the loop instead of appearing statically.
-
Gradient fills — parses <radialGradient> and <linearGradient> from each state's <defs>, applies gradientTransform matrix to derive user-space center + radius, and emits SwiftUI gradients using .mask(Path) so UnitPoint normalizes to the canvas (matching SVG's userSpaceOnUse semantics).
-
Per-state flat fills animate — when a shared id's fill differs across states and every value is a plain color, the exporter emits a fillColor(for:in:) lookup driven by a fillState updated inside the morph's withAnimation, so colors interpolate along the same curve as the shapes (mirrors the JS runFillTween). Emitted only when fills actually differ.
-
Particle templates — for any state with a particles config, ALL orphans (orphanSVG top-level groups + structured _orphan: true paths) become spawn templates rather than static decorations. Each template renders to a UIImage at export time via UIKit drawing; CAEmitterLayer uses one CAEmitterCell per template so the emitter randomly spawns each variant.
-
Particle gating — emission stops immediately at the start of any transition leaving a particle state (via particlesEmitting @State and cell.birthRate = 0); alive cells finish their lifetime naturally.
-
Particle z-order — when the source config sets layer: "behind", particles render in the canvas ZStack BEFORE shared paths.
-
Bitmap scale — particle template UIImages are rendered at format.scale = 1.0 so CAEmitterLayer sizes them by their logical point dimensions (otherwise particles render at 2x/3x on Retina).
-
Easing — presets AND custom curves — a named preset maps to Animation.timingCurve(...) with control points matching the JS engine's easeFns; a curve the user dragged in the preview's bezier editor exports verbatim via opts.easingBezier (it wins over the named ease, same precedence as the JS engine). The edited duration rides along.
-
Per-path method overrides — the path editor's methods export via opts.methodOverrides. CROSSFADE compiles to two static per-state stacks with a CrossfadeFadeMod: AnimatableModifier (displayed fades out in the first half, target fades in over the second, sine-eased); SCALE to a ScaleMorphMod: AnimatableModifier rigidly mapping the displayed bbox onto the target's (per-state bboxes baked via scaleBox(for:in:)). Forced MORPH overrides detection; a forced TRANSFORM only holds when structures actually match (control points pair 1:1), otherwise it falls back with a console warning. Both new modifiers are emitted only when used, so override-free exports are unchanged.
-
Idle preset + anchor overrides — opts.idleOverride replaces every state's authored idle before generation (root, per-element, and header docs all agree); opts.idleAnchor (or an authored per-part origin) becomes IdlePart.anchor: UnitPoint and overrides each kind's default pivot in evaluateIdle — same precedence as the JS engine's originOverride.
-
Strategy fingerprinting normalizes H/V → L at parse time (matching the engine), so cross-syntax-identical paths route to TRANSFORM in Swift too.
-
Background color is NOT exported — the preview's canvas color is preview-only; the generated view renders on a transparent canvas (the header comment says so) — style the host view instead.
The preview's Export panel passes ALL of the above automatically, so the .swift build matches exactly what's on screen. Generated output (with and without overrides) is verified with swiftc -typecheck.
What still requires manual attention if your animation uses it:
- Loop first-cycle phase —
pop-loop / orphanPopLoop derive a star/note's state purely from t, so the loop starts from its natural phase at t = 0. The JS engine's orphanPopLoop has a skipAppear first cycle (the entrance pop already placed the orphans, so cycle 0 begins at the hold); the Swift export instead fades them in from the loop's start. Steady-state behavior is identical; only the very first ~1s differs.
- Per-element idle blur/filter — idle-driven orphans drop their SVG
filter (blur/glow) like all orphans; the motion is faithful but a blurred element animates as its un-blurred shape.
- Elliptical gradients with skew — approximated as circular with
max(rX, rY) radius. Visual approximation, not exact.
- Interrupt smoothness — JS reads each path's live
d mid-morph to retarget smoothly; Swift re-snaps to displayedState's geometry. Acceptable for most use cases.
- 3+ point matrix transforms with skew —
parseTransformAttr composes translate/rotate/scale correctly but a fully arbitrary skew matrix on a path is unusual and may not bake perfectly.
The script is the canonical "design in browser, ship to iOS" pipeline. If a new character needs a feature not in the list above, extend the generator rather than hand-editing the output.
Bundled assets
templates/MorphAnimator.jsx — canonical production React component (ES modules, build step). Copy as the starting point and replace the STATES constant with the user's actual data.
templates/App.jsx — canonical production demo wrapper (ES modules) with state buttons, preset selectors, background color picker, and strategy panel.
templates/preview/ — zero-build browser preview harness (React + Babel via CDN). See "Preview template" section above.
assets/preset-library.json — all presets as structured data
scripts/figma-to-states.js — workflow notes for fetching N Figma frames via MCP and producing paired SVG strings
Output format checklist
Before returning the component, verify: