---
name: text-to-lottie
description: Author a Lottie (Bodymovin) JSON animation that renders in a local skia player. Use whenever the user asks to create, generate, edit, or fix a Lottie animation, or asks for "an animation" to load.
---Authoring Renderable Lottie Files
This app renders Lottie with Skia's Skottie module (via canvaskit-wasm),
not the JS lottie-web runtime. Follow the rules below and
verify the result.
This skill covers the mechanics — the JSON shape Skottie needs. For the craft (timing, easing, choreography, Disney animation principles), see LottieFiles' motion-design skill. Its guidance is in milliseconds; convert to frames with
frames = ms / 1000 * fr.
Setting up the project
The deliverable is not just public/lottie.json: the viewer should be set up
and the animation should be previewable in the browser. If the player project is
missing, create it; if it exists, install/update dependencies as needed, start
the dev server, and open the local preview URL for verification.
Always use the official GitHub player project — never hand-roll a custom
viewer. This skill's JSON rules (slots, the properties panel, the ?frame=
URL controls, the Skottie wasm wiring) only hold inside that exact project. Do
not build your own HTML page, swap in lottie-web, or scaffold a bespoke
canvas setup — any of those will silently diverge from how this player renders
and the verification steps below won't apply. If the player project isn't
already on this machine, scaffold a fresh copy of the repo with degit:
npx degit diffusionstudio/lottie my-animation
cd my-animation
npm install # postinstall copies the CanvasKit wasm into /public
npm run dev
Then open the printed local URL. If you already have the project, just
npm install && npm run dev.
Where to write the file (and how it loads)
- Write the animation JSON to
public/lottie.json. That is the only file you need to touch to change what the app shows —src/App.tsxfetches/lottie.jsonat startup. - With the dev server running (
npm run dev), a Vite plugin watches that file and full-reloads the page on save, so your edit appears immediately. No other wiring is required. - If parsing fails, the app shows the error on screen ("CanvasKit could not parse the Lottie file.").
Required top-level shape
Every Lottie document is one JSON object with at least these fields:
{
"v": "5.7.0", // bodymovin version string
"fr": 60, // frame rate (fps)
"ip": 0, // in point (start frame)
"op": 120, // out point (end frame) — duration = (op - ip) / fr seconds
"w": 512, // composition width (px)
"h": 512, // composition height (px)
"assets": [], // images / precomps; [] if none
"layers": [ /* ... */ ]
}
The app letterboxes the w×h composition to fit the canvas, so pick a square
or sensible aspect ratio. op controls the total frame count shown in the UI.
Layers
layers follows After Effects order: the first entry in the array is the
topmost layer, and later entries render underneath it. Each layer needs at
minimum:
{
"ty": 4, // layer type: 4 = shape layer (the common case)
"nm": "circle", // name (optional but helpful)
"ip": 0, // layer in point
"op": 120, // layer out point — must cover the frames you want it visible
"st": 0, // start time
"ks": { /* transform — see below */ },
"shapes": [ /* ... */ ] // for shape layers
}
Common layer types: 4 shape, 2 image, 1 solid, 0 precomp, 5 text.
Prefer shape layers (ty: 4) for LLM-authored animations — no external
assets needed.
The transform block (ks)
Every layer has a transform. Each property is either static ({ "a": 0, "k": value })
or animated ({ "a": 1, "k": [ ...keyframes ] }).
"ks": {
"o": { "a": 0, "k": 100 }, // opacity 0–100
"r": { "a": 0, "k": 0 }, // rotation (degrees)
"p": { "a": 0, "k": [256, 256, 0] }, // position [x, y, z]
"a": { "a": 0, "k": [0, 0, 0] }, // anchor point [x, y, z]
"s": { "a": 0, "k": [100, 100, 100] } // scale (percent, per axis)
}
Anchor matters: rotation and scale pivot around the anchor a, expressed in
the layer's own coordinate space. To rotate a shape around its own center, set
the shape's geometry around the anchor (e.g. center the ellipse on a).
Shapes — the #1 Skottie gotcha
Skottie requires shape elements to be wrapped in a Group (ty: "gr"). A flat
list of shapes + fills directly in shapes renders blank. Always nest the
geometry, fill/stroke, and a group transform inside a group's it array:
"shapes": [
{
"ty": "gr", // GROUP — required wrapper
"nm": "ball",
"it": [
{
"ty": "el", // ellipse
"p": { "a": 0, "k": [0, 0] },
"s": { "a": 0, "k": [120, 120] }
},
{
"ty": "fl", // fill
"c": { "a": 0, "k": [0.2, 0.6, 1, 1] }, // RGBA, each 0–1
"o": { "a": 0, "k": 100 }
},
{
"ty": "tr", // GROUP TRANSFORM — include even if identity
"p": { "a": 0, "k": [0, 0] },
"a": { "a": 0, "k": [0, 0] },
"s": { "a": 0, "k": [100, 100] },
"r": { "a": 0, "k": 0 },
"o": { "a": 0, "k": 100 }
}
]
}
]
Shape primitives inside it:
"el"ellipse —pcenter,s[width, height]"rc"rectangle —pcenter,s[w, h],rcorner radius"sh"custom path —ks.kis a bezier{ "c": closed?, "v": verts, "i": inTangents, "o": outTangents }"st"stroke —ccolor,wwidth,oopacity"fl"fill —ccolor (RGBA 0–1),oopacity"tr"the group's transform (always include it last)
Colors are normalized 0–1 RGBA, not 0–255. [1, 0, 0, 1] is opaque red.
Animating a property (keyframes)
Set "a": 1 and make k an array of keyframe objects. Each keyframe has a
time t (frame), a value s (start value for that segment, as an array), and
easing handles i/o:
"p": {
"a": 1,
"k": [
{ "t": 0, "s": [256, 120], "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] } },
{ "t": 60, "s": [256, 400], "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] } },
{ "t": 120, "s": [256, 120] }
]
}
tis the frame number; the last keyframe usually has noi/o/easing pair beyonds(it's the end).sis always an array, even for scalars like rotation:"s": [360].i/oare the bezier ease handles (incoming / outgoing).x/yarrays in[0..1]. For a smooth ease usex:[0.5], y:[1](in) andx:[0.5], y:[0](out); for linear usex:[0], y:[0]/x:[1], y:[1]. Multi-dimensional values may use per-axis arrays.- To loop seamlessly, make the last keyframe's value equal the first.
Exposing editable properties (slots + the properties panel)
The app can render a live properties panel (text inputs and sliders) that edit chosen values of the animation in real time. This rides on Skottie's native slot feature — no re-parse, the change shows on the next frame.
To make a property editable, do two things:
1. Declare a slot in the Lottie JSON. Add a top-level "slots" object whose
keys are slot IDs, and point a property at one with "sid" instead of (or
alongside) an inline value. The slot's "p" holds the default, in the same
shape the property would normally take.
{
"v": "5.7.0", "fr": 60, "ip": 0, "op": 90, "w": 512, "h": 512, "assets": [],
"slots": {
"ballColor": { "p": { "a": 0, "k": [0.231, 0.6, 1, 1] } }, // color: RGBA 0–1
"ballSize": { "p": { "a": 0, "k": 120 } } // scalar
},
"layers": [ /* ... */
// in the fill: "c": { "sid": "ballColor" }
// in a scalar: "s": { "sid": "ballSize" }
]
}
Slot types map to controls like this:
| Slot value | Control rendered |
|---|---|
| scalar (a single number) | slider |
| color (RGBA 0–1) | color picker |
vec2 ([x, y]) |
two number inputs |
| text (a string) | text input |
The app discovers slots automatically via Skottie's getSlotInfo() — you do
not list them anywhere else for them to work. The panel appears as soon as
the animation declares at least one slot.
Required: a background-color control on every animation
Every animation you produce must expose at least one control for the
background color. The player does not paint a composition background of its
own, so add a full-composition background layer as the last entry in
layers (so it renders underneath everything), fill it with a slotted color,
and label that slot in controls.json. Use a rectangle the size of the
composition:
// last layer in `layers`:
{
"ty": 4, "nm": "background", "ip": 0, "op": 120, "st": 0,
"ks": { "o": { "a": 0, "k": 100 }, "p": { "a": 0, "k": [256, 256, 0] },
"a": { "a": 0, "k": [0, 0, 0] }, "s": { "a": 0, "k": [100, 100, 100] },
"r": { "a": 0, "k": 0 } },
"shapes": [
{ "ty": "gr", "it": [
{ "ty": "rc", "p": { "a": 0, "k": [256, 256] },
"s": { "a": 0, "k": [512, 512] }, "r": { "a": 0, "k": 0 } },
{ "ty": "fl", "c": { "sid": "bgColor" }, "o": { "a": 0, "k": 100 } },
{ "ty": "tr", "p": { "a": 0, "k": [0, 0] }, "a": { "a": 0, "k": [0, 0] },
"s": { "a": 0, "k": [100, 100] }, "r": { "a": 0, "k": 0 },
"o": { "a": 0, "k": 100 } }
] }
}
// slots: "bgColor": { "p": { "a": 0, "k": [1, 1, 1, 1] } } // default white
// controls: { "sid": "bgColor", "label": "Background color" }
Match the rectangle's p/s to your composition's w×h. This is in addition
to whatever other controls the animation exposes.
2. (Optional) Describe presentation in public/controls.json. Slots only
expose an ID and type, not a label or a sensible slider range. The sidecar file
adds that. It is optional — missing entries fall back to the slot ID and a
generic 0–100 range. Like lottie.json, it hot-reloads on save.
{
"controls": [
{ "sid": "ballColor", "label": "Ball color" },
{ "sid": "ballSize", "label": "Ball size", "min": 40, "max": 240, "step": 1 }
]
}
sidmust match a slot ID exactly.labelis the display name;min/max/stepshape scalar sliders and vec2 inputs (ignored for color/text).- An entry whose
sidmatches no slot is simply ignored; a slot with no entry still renders with defaults.
Controlling playback from a browser agent
When you drive the page through a browser tool, do not pixel-drag the slider or hunt for the play button — it's unreliable and you can't land on an exact frame. Instead, pin the frame in the URL and read the canvas by its test id:
http://localhost:5173/?frame=60&paused=1
?frame=Nseeks to frameNon load and holds it paused, so the moment sits still for a screenshot. This is the right way to inspect a specific frame (e.g. "is the ball at the bottom at frame 60?"): open?frame=60, then screenshot.?paused=1starts paused (at frame 0, or atframeif also given);?paused=0forces autoplay even with a frame pinned.- With no query params the animation autoplays as usual.
To change the inspected frame, navigate to a new URL (or just edit the query
string and reload). The canvas carries data-testid="lottie-canvas", so a
browser tool can target it directly for screenshots. If the canvas is blank,
the page hasn't finished loading or the Lottie failed to parse (check the
on-screen error).
Before you finish — checklist
- The file is valid JSON (no comments, no trailing commas). Validate with
node -e "JSON.parse(require('fs').readFileSync('public/lottie.json','utf8'))". - Every shape primitive/fill is inside a
"ty": "gr"group'sitarray, and each group ends with a"tr"transform. - Top-level
opand each layer'sopcover the frames you animate. - Colors are 0–1 RGBA; positions/sizes are within the
w×hcomposition. - Keyframe
svalues are arrays; loops repeat the first value at the end. - A background-color control is present: a full-composition background layer
(last in
layers) with a slotted fill (e.g.bgColor) and a matchingcontrols.jsonlabel. - The project is the official GitHub player (scaffolded via degit), not a custom/hand-rolled viewer.
- If the dev server is running, just save — it hot-reloads. Otherwise start it
with
npm run dev. A blank canvas (no error) → re-check the group wrapping. - The player is running and the preview URL has been opened or reported. When a
browser tool is available, verify the page shows a nonblank rendered
animation before finalizing — pin a key frame via the URL (see "Controlling
playback from a browser agent"), e.g. open
?frame=60&paused=1and screenshot, rather than dragging the on-screen slider.