| 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
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.tsx
fetches /lottie.json at 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",
"fr": 60,
"ip": 0,
"op": 120,
"w": 512,
"h": 512,
"assets": [],
"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,
"nm": "circle",
"ip": 0,
"op": 120,
"st": 0,
"ks": { },
"shapes": [ ]
}
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 },
"r": { "a": 0, "k": 0 },
"p": { "a": 0, "k": [256, 256, 0] },
"a": { "a": 0, "k": [0, 0, 0] },
"s": { "a": 0, "k": [100, 100, 100] }
}
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",
"nm": "ball",
"it": [
{
"ty": "el",
"p": { "a": 0, "k": [0, 0] },
"s": { "a": 0, "k": [120, 120] }
},
{
"ty": "fl",
"c": { "a": 0, "k": [0.2, 0.6, 1, 1] },
"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 }
}
]
}
]
Shape primitives inside it:
"el" ellipse — p center, s [width, height]
"rc" rectangle — p center, s [w, h], r corner radius
"sh" custom path — ks.k is a bezier { "c": closed?, "v": verts, "i": inTangents, "o": outTangents }
"st" stroke — c color, w width, o opacity
"fl" fill — c color (RGBA 0–1), o opacity
"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] }
]
}
t is the frame number; the last keyframe usually has no i/o/easing pair
beyond s (it's the end).
s is always an array, even for scalars like rotation: "s": [360].
i/o are the bezier ease handles (incoming / outgoing). x/y arrays in
[0..1]. For a smooth ease use x:[0.5], y:[1] (in) and x:[0.5], y:[0]
(out); for linear use x:[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] } },
"ballSize": { "p": { "a": 0, "k": 120 } }
},
"layers": [
]
}
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:
{
"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 } }
] }
}
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 }
]
}
sid must match a slot ID exactly.
label is the display name; min/max/step shape scalar sliders and vec2
inputs (ignored for color/text).
- An entry whose
sid matches 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=N seeks to frame N on 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=1 starts paused (at frame 0, or at frame if also given);
?paused=0 forces 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's it array, and
each group ends with a "tr" transform.
- Top-level
op and each layer's op cover the frames you animate.
- Colors are 0–1 RGBA; positions/sizes are within the
w×h composition.
- Keyframe
s values 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 matching
controls.json label.
- 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=1 and
screenshot, rather than dragging the on-screen slider.