references/motion-custom-styles.md
# Custom Animation Styles
Use this reference when a `use_figma` task discovers, creates, edits, organizes, applies, or removes a **local custom animation style** — a reusable, named motion style saved in the file.
Same Plugin API rules as the rest of this skill: top-level `await`, `return` for output, no `figma.closePlugin()` / `figma.notify()`, return every mutated node ID. Motion APIs might not be enabled for the calling user — see Runtime Gating in [figma-use-motion/SKILL.md](../SKILL.md).
## API Availability
Custom animation styles might not be enabled even when keyframes and first-party styles work. When unavailable, `figma.createCustomAnimationStyle()` and `figma.getLocalCustomAnimationStylesAsync()` throw `"not a supported API"`, `applyAnimationStyle(id, { type: 'CUSTOM' })` throws `Custom animation styles are not available`, and `node.animationStyles` **silently omits custom bindings**, so an empty read is not proof the node has no custom style applied. On any of these, **stop and explain the limitation**—do not automatically fall back to manual keyframes or a first-party style, because adding a fallback can stack a second animation on motion the agent cannot inspect. Get explicit user agreement before writing any fallback.
## Exposed Custom Animation API Surface
- `figma.getLocalCustomAnimationStylesAsync()` — local `CustomAnimationStyle` objects authored in this file. Prefer this over the deprecated sync `getLocalCustomAnimationStyles()`, which fails under dynamic-page document access.
- `figma.createCustomAnimationStyle()` — create a local custom style. `style.addAnimationEntryAsync(definitionStyleId, props)` / `style.removeAnimationEntry(entryId)` change its contents; `style.animationEntries` reads them back; `style.remove()` deletes the style.
- `figma.moveLocalCustomAnimationStyleAfter(target, reference)` / `figma.moveLocalCustomAnimationFolderAfter(targetPath, referencePath)` — order local custom styles and their slash-delimited folders.
**Two different things are called an "animation style".** `figma.motion.figmaAnimationStyles()` returns Figma's read-only first-party *definitions* (Fade, Move, …) — you can apply and configure them, but you cannot author new ones. A local `CustomAnimationStyle` is a reusable style saved in the file, and it is an ordered list of those first-party definitions. Both are applied through `applyAnimationStyle` / `node.animationStyles`, discriminated by `type: 'FIGMA'` vs `type: 'CUSTOM'`.
So when the user wants a brand-new *reusable, named* animation style, build it as a local custom animation style composed of first-party definitions. What remains out of scope is authoring `"figma:motion"` preset module source code: you cannot invent a new first-party definition or a motion primitive no definition provides. If the requested motion can't be expressed by composing definitions, use manual keyframes instead of fabricating a definition.
## Two Kinds Of "Animation Style"
| | First-party definition | Local custom style |
|---|---|---|
| Comes from | `figma.motion.figmaAnimationStyles()` | `figma.getLocalCustomAnimationStylesAsync()` |
| What it is | A read-only definition (Fade, Move, …) with advertised `props` | A `CustomAnimationStyle` object (`type: "CUSTOM_ANIMATION"`) holding an **ordered list of first-party definitions** |
| Authorable | No — you cannot write new definitions | Yes — create, rename, add/remove entries, delete |
| Applied as | `{ type: 'FIGMA', duration, timelineOffset, props }` | `{ type: 'CUSTOM', timelineOffset }` |
`node.animationStyles` returns both as a discriminated union. **Narrow on `type` before reading type-specific fields** — custom bindings expose `type`, `id`, `styleId`, `name`, and optional `timelineOffset`, and do *not* carry the Figma-only `duration` / `props`.
Custom styles do not nest. `addAnimationEntryAsync()` takes a first-party definition's `styleId`, never another custom style's `id`.
## Standard Workflow
1. **Discover before creating.** List local custom styles and reuse a match instead of adding a near-duplicate.
2. **Inspect the definitions** with `figma.motion.figmaAnimationStyles()` and read each one's advertised `props` before configuring an entry.
3. **Add entries in the intended order** — ordering is append-only (see [Editing a style's contents](#editing-a-styles-contents)).
4. **Apply to the target node** with `{ type: 'CUSTOM' }`.
5. **Read back both** the style's entries and the node binding.
6. **Extend the timeline** with `setTimelineDuration` if the applied motion now exceeds it.
## Discover And Inspect
Always use the async getter; the sync `getLocalCustomAnimationStyles()` is deprecated and fails under dynamic-page document access.
```js
const styles = await figma.getLocalCustomAnimationStylesAsync();
return styles.map((style) => ({
id: style.id,
name: style.name,
type: style.type, // "CUSTOM_ANIMATION"
entries: style.animationEntries.map((entry) => ({
id: entry.id,
name: entry.name,
duration: entry.duration,
props: entry.props,
})),
}));
```
Match by `id` whenever the task already established one. Names are **not unique** — if a name match returns more than one style, disambiguate from entry contents and consumers rather than picking arbitrarily:
```js
const styles = await figma.getLocalCustomAnimationStylesAsync();
const matches = styles.filter((style) => style.name === "Gentle entrance");
return {
matchCount: matches.length,
consumers: matches.length === 1 ? await matches[0].getStyleConsumersAsync() : [],
};
```
## Create A Style
Create it, name it, then append first-party definitions in order. The second argument to `addAnimationEntryAsync` is the **entry's props object** — `duration` and easing live *inside* it. This differs from applying a Figma style directly to a node, where `duration` is a top-level field.
```js
const defs = figma.motion.figmaAnimationStyles();
const fadeDef = defs.find((def) => def.styleId === "Opacity");
if (!fadeDef) {
throw new Error("Could not find the Fade animation definition.");
}
const style = figma.createCustomAnimationStyle();
try {
style.name = "Gentle entrance";
await style.addAnimationEntryAsync(fadeDef.styleId, {
duration: 0.4,
easing: { type: "EASE_OUT" },
// Only properties advertised by fadeDef.props.
});
} catch (error) {
style.remove(); // Don't leave an orphan empty style behind.
throw error;
}
return {
styleId: style.id,
entries: style.animationEntries,
};
```
A returned entry's `styleId` identifies the generated entry, **not** the source definition. Never feed `entry.styleId` back into `addAnimationEntryAsync` to duplicate an entry — rediscover the definition from `figma.motion.figmaAnimationStyles()` instead.
## Apply A Style
Pass the custom style's `id` plus the `CUSTOM` discriminator. A custom style carries its own timing, so `timelineOffset` is the only thing configurable at application time.
```js
const node = await figma.getNodeByIdAsync("123:456");
const styles = await figma.getLocalCustomAnimationStylesAsync();
const style = styles.find((candidate) => candidate.id === "S:abc,");
const mutatedNodeIds = [node.id];
if (!style) {
throw new Error("Could not find the requested custom animation style.");
}
const bindingId = node.applyAnimationStyle(style.id, {
type: "CUSTOM",
timelineOffset: 0.25,
});
const [timeline] = node.timelines;
const requiredDuration = 0.25 + 0.4; // offset + the style's own motion length
if (timeline && timeline.duration < requiredDuration) {
node.setTimelineDuration(timeline.id, requiredDuration);
mutatedNodeIds.push(timeline.id);
}
return {
mutatedNodeIds,
bindingId,
animationStyles: node.animationStyles,
};
```
Two things to keep straight:
- The returned `bindingId` identifies **this application on this node**; it is not `style.id`. Keep it if the task will later edit or remove that specific application.
- Do **not** pass `duration` or `props` alongside `type: 'CUSTOM'`. The input schema is strict, so those fields are rejected rather than silently dropped.
## Read, Edit, Or Remove An Application
```js
const node = await figma.getNodeByIdAsync("123:456");
return {
custom: node.animationStyles.filter((applied) => applied.type === "CUSTOM"),
figma: node.animationStyles.filter((applied) => applied.type === "FIGMA"),
};
```
Remove one application by its binding id:
```js
const node = await figma.getNodeByIdAsync("123:456");
node.removeAnimationStyle(bindingId);
return { mutatedNodeIds: [node.id], animationStyles: node.animationStyles };
```
For batch edits, write the **complete desired array** and preserve unrelated bindings. A custom input needs `type: 'CUSTOM'` and `styleId`; include the existing binding `id` to update that application in place:
```js
const node = await figma.getNodeByIdAsync("123:456");
node.animationStyles = node.animationStyles.map((applied) =>
applied.type === "CUSTOM" && applied.id === bindingId
? { type: "CUSTOM", id: applied.id, styleId: applied.styleId, timelineOffset: 0.5 }
: applied,
);
return { mutatedNodeIds: [node.id], animationStyles: node.animationStyles };
```
- Omitting an existing binding **removes** it; writing `[]` clears both custom and Figma styles.
- To swap the underlying style while keeping the same binding, keep `id` and change `styleId`.
- Don't use an array write to reorder applications — matched entries update in place and new ones append.
## Editing A Style's Contents
`style.name` is writable. `style.animationEntries` and the entry fields are read-only, so content changes go through:
- `await style.addAnimationEntryAsync(def.styleId, props)` — append an entry.
- `style.removeAnimationEntry(entry.id)` — remove an entry.
- `style.remove()` — delete the reusable style itself. Only when the user asked for that; it affects every consumer.
There is no in-place entry update and no reorder — removing and re-adding appends to the end. When exact order must change, rebuild the entries in order only after mapping every existing entry back to an available first-party definition *and* preserving its configured props. If that mapping is ambiguous, stop and ask rather than silently substituting a different animation.
Entries can only be added to or removed from **local** styles; mutating a remote (library) style throws `Cannot add animation entries to a remote style`.
## Organize Local Styles
Slash-delimited names create folders:
```js
style.name = "Motion/Entrances/Gentle entrance";
```
Reorder only when the user asks for library organization. Target and reference must both be local custom animation styles in the same folder; pass `null` as the reference to move the target first.
```js
figma.moveLocalCustomAnimationStyleAfter(targetStyle, referenceStyle);
```
Folders reorder the same way, by full slash-delimited path, and must share a parent:
```js
figma.moveLocalCustomAnimationFolderAfter("Motion/Entrances", "Motion/Exits");
```
## Verification
- [ ] Custom-style work listed existing local styles via `figma.getLocalCustomAnimationStylesAsync()` before creating another, and matched by `id` rather than by non-unique `name`.
- [ ] Custom styles are applied with `{ type: 'CUSTOM' }` and at most a `timelineOffset`; `duration` and `props` are rejected on a custom binding, not ignored.
- [ ] Custom-style verification read back *both* the style's `animationEntries` and the node binding (`type: 'CUSTOM'` with the expected `styleId`) — one can succeed while the other silently didn't.
Verify the reusable definition and its application **separately** — one can succeed while the other silently didn't.
```js
const node = await figma.getNodeByIdAsync("123:456");
const styles = await figma.getLocalCustomAnimationStylesAsync();
const style = styles.find((candidate) => candidate.id === styleId);
const applied = node.animationStyles.find((candidate) => candidate.id === bindingId);
return {
style: style && { id: style.id, name: style.name, entries: style.animationEntries },
applied,
timelines: node.timelines,
};
```
Treat it as verified only when all of these hold:
- the style exists with the expected ordered entries and props;
- the node binding has `type: 'CUSTOM'` and the expected `styleId`;
- its `timelineOffset` is what you wrote; and
- the containing timeline is long enough for the resulting motion.
references/motion-easing.md
# Motion Easing
Use this reference when setting `easing` on a `manualKeyframeTracks` keyframe or on an `animationStyles[].props.easing` value in `use_figma` scripts.
The motion easing object is the same shape used by smart-animate transitions, with `'HOLD'` added for motion-only step interpolation. The full set of accepted `type` values is listed below.
## Easing Object
```js
{ type: "EASE_OUT" }
```
`type` accepts any of:
| Value | Meaning |
|---|---|
| `EASE_IN` | Ease in (cubic) |
| `EASE_OUT` | Ease out (cubic) |
| `EASE_IN_AND_OUT` | Ease in and out (cubic) |
| `LINEAR` | Linear |
| `EASE_IN_BACK` | Ease in back |
| `EASE_OUT_BACK` | Ease out back |
| `EASE_IN_AND_OUT_BACK` | Ease in and out back |
| `CUSTOM_CUBIC_BEZIER` | Custom cubic bezier (requires `easingFunctionCubicBezier`) |
| `GENTLE` | Gentle spring |
| `QUICK` | Quick spring |
| `BOUNCY` | Bouncy spring |
| `SLOW` | Slow spring |
| `CUSTOM_SPRING` | Custom spring (requires `easingFunctionSpring`) |
| `HOLD` | Hold / step interpolation (motion-only; do not include any extra fields) |
The easing on a keyframe controls how to animate from the previous keyframe to the current keyframe.
**Do not** use internal scenegraph names like `OUT_CUBIC`, `INOUT_CUBIC`, `CUSTOM_CUBIC`, `SPRING_PRESET_ONE`, etc. They're rejected by the public schema.
**Do not** shorten ease-in-out enum names. The public schema accepts `EASE_IN_AND_OUT` and `EASE_IN_AND_OUT_BACK`; it rejects `EASE_IN_OUT` and `EASE_IN_OUT_BACK`.
## Custom Cubic Bezier
`CUSTOM_CUBIC_BEZIER` requires `easingFunctionCubicBezier`:
```js
{
type: "CUSTOM_CUBIC_BEZIER",
easingFunctionCubicBezier: { x1: 0.34, y1: 1.56, x2: 0.64, y2: 1 }
}
```
Example on a manual keyframe track:
```js
const node = await figma.getNodeByIdAsync("123:456");
node.manualKeyframeTracks = {
...(node.manualKeyframeTracks ?? {}),
TRANSLATION_X: {
keyframes: [
{
timelinePosition: 0,
value: { type: "FLOAT", value: 0 },
},
{
timelinePosition: 1,
value: { type: "FLOAT", value: 120 },
easing: {
type: "CUSTOM_CUBIC_BEZIER",
easingFunctionCubicBezier: { x1: 0, y1: 0, x2: 0.58, y2: 1 },
},
},
],
},
};
return {
mutatedNodeIds: [node.id],
manualKeyframeTracks: node.manualKeyframeTracks,
};
```
## Custom Spring
`CUSTOM_SPRING` requires `easingFunctionSpring`. Use `bounce` (a number from 0 to 1):
```js
{
type: "CUSTOM_SPRING",
easingFunctionSpring: { bounce: 0.3 }
}
```
`bounce` controls how springy the motion feels: `0` settles smoothly with no overshoot, and higher values (up to `1`) add progressively more oscillation. The API enforces `bounce >= 0 && bounce <= 1`.
If you are starting from physical spring params, convert them before writing:
```js
const bounce = figma.motion.physicalSpringToNormalized({ mass: 1, stiffness: 100, damping: 10 });
{
type: "CUSTOM_SPRING",
easingFunctionSpring: { bounce }
}
```
When reading motion keyframes back, `easingFunctionSpring` includes only `bounce`.
## Hold
`HOLD` keeps the previous keyframe value until the current keyframe is reached, then steps immediately to the current value. Do not include `easingFunctionCubicBezier` or `easingFunctionSpring`.
```js
{
type: "HOLD"
}
```
Use `HOLD` for discrete state changes, step reveals, or intentionally abrupt toggles.
## Easing Inside An Animation Style
Some animation styles expose an `easing` prop. First inspect the style definition's `props` to confirm the name and accepted shape:
```js
const styles = figma.motion.figmaAnimationStyles();
const fadeDef = styles.find((style) => style.name === "Fade");
return {
fadeStyle: fadeDef
? {
styleId: fadeDef.styleId,
name: fadeDef.name,
props: fadeDef.props,
}
: null,
};
```
When the style prop accepts an easing, use the same public easing object:
```js
const node = await figma.getNodeByIdAsync("123:456");
const styles = figma.motion.figmaAnimationStyles();
const fadeDef = styles.find((style) => style.name === "Fade");
if (!fadeDef) {
throw new Error("Could not find the Fade animation style.");
}
node.animationStyles = [
...(node.animationStyles ?? []),
{
styleId: fadeDef.styleId,
name: fadeDef.name,
duration: 0.5,
timelineOffset: 0,
props: {
timing: "in",
easing: {
type: "CUSTOM_CUBIC_BEZIER",
easingFunctionCubicBezier: { x1: 0, y1: 0, x2: 0.58, y2: 1 },
},
},
},
];
return {
mutatedNodeIds: [node.id],
animationStyles: node.animationStyles,
};
```
references/motion-patterns.md
# Motion Patterns
Use this reference when a `use_figma` task adds, edits, removes, or inspects Motion animation on Figma design nodes.
This is for Plugin API code executed through the MCP `use_figma` tool. Follow the main `figma-use` rules: use top-level `await`, use `return` for output, do not call `figma.closePlugin()`, do not use `figma.notify()`, and return every mutated node ID. API availability and the exposed-API surface are summarised in [figma-use-motion/SKILL.md](../SKILL.md) — bail closed on `"not a supported API"`.
**`node.animations` reflects manual-keyframe tracks only at present.** Applying an animation style writes its metadata to `node.animationStyles` but the style-generated tracks are not materialized into `node.animations` yet. Validate style writes by reading back `node.animationStyles`, not `node.animations`.
## Standard Workflow
1. Inspect the target nodes first. Check selection, parentage, existing `manualKeyframeTracks`, existing `animationStyles`, and `timelines`.
2. Pick the animation type:
- If the user explicitly asks for keyframes or an animation style, use the requested type.
- For motion within text (characters, words, lines, paragraphs, or typewriter reveals), use the discovered native `TextStagger` preset. Load [text-animation.md](text-animation.md); keep the text editable instead of splitting it into nodes.
- Otherwise, default to manual keyframes for new motion. They are the most direct way to express node-specific timing, choreography, fills/strokes/effects, and custom keyframe values.
- Use animation styles when the requested motion matches an existing reusable style, when the design system has motion variables/tokens you should bind to, or when matching existing style usage on neighbouring nodes.
3. Preserve static design data. Do not create, delete, detach, or restructure nodes unless the user asked for it.
4. Write the smallest useful motion change. Spread existing tracks or styles unless intentionally replacing them.
5. Extend the containing timeline with `setTimelineDuration` only when the animation exceeds the current duration. Do not shorten it unless asked.
6. Validate by returning the updated track or style data and the mutated node IDs.
Do not animate top-level frames that are direct children of the page. Animate their children or descendants instead.
## Inspect Motion State
Use a read-only script before making changes:
```js
const nodes = figma.currentPage.selection;
return nodes.map((node) => ({
id: node.id,
name: node.name,
type: node.type,
parentType: node.parent?.type ?? null,
timelines: node.timelines,
manualKeyframeTracks: node.manualKeyframeTracks,
animationStyles: node.animationStyles,
}));
```
For a known node ID:
```js
const node = await figma.getNodeByIdAsync("123:456");
return {
id: node.id,
name: node.name,
timelines: node.timelines,
manualKeyframeTracks: node.manualKeyframeTracks,
animationStyles: node.animationStyles,
};
```
## Manual Keyframes
Manual keyframes are stored on `node.manualKeyframeTracks`. Top-level keys are animatable fields. A track contains a `keyframes` array, and each keyframe describes the value at a timeline position.
When creating a new manual keyframe track, omit `baseValue`; the API derives it from the node. When editing an existing track, preserve the existing track `id`, `baseValue`, and keyframe `id`s unless the user explicitly asks to replace that data.
Timeline positions are in seconds.
```js
{
timelinePosition: 1,
value: { type: "FLOAT", value: 120 },
easing: { type: "EASE_OUT" }
}
```
The easing on a keyframe controls how to animate from the previous keyframe to this keyframe. See [motion-easing.md](motion-easing.md) for the full easing object shape (including `CUSTOM_CUBIC_BEZIER`, `CUSTOM_SPRING`, and `HOLD`).
### Animatable Fields
The Plugin API accepts only the public allowlist below. Do not use generated/internal scenegraph field names even if they appear in codegen types: fields such as `SHEAR`, `SCROLL_OFFSET_X`, `SCROLL_OFFSET_Y`, `DISSOLVE_PROGRESS`, `MEDIA_CURRENT_TIME`, 3D transform fields, polygon/arc fields, and `VARIANT_PROPERTIES` intentionally throw.
Two semantic categories. Read the "Compose with" column before writing values — these are the only differences agents trip on.
**Transform fields — compose with the node's existing transform.** A value of `0` (or `1` for scale) means "no change from the node's resting transform". The value you write is added to (or multiplied with) the current value, not substituted for it.
| Field | Value | Compose with | Notes |
|---|---|---|---|
| `TRANSLATION_X` | `FLOAT` pixels | additive (neutral `0`) | Positive moves right. |
| `TRANSLATION_Y` | `FLOAT` pixels | additive (neutral `0`) | Positive moves down. |
| `TRANSLATION_XY` | `VECTOR` pixels | additive (neutral `{ x: 0, y: 0 }`) | Use when X and Y should stay in one track. |
| `ROTATION` | `FLOAT` degrees | additive (neutral `0`) | Positive rotates counter-clockwise, matching `node.rotation`; negative rotates clockwise. Unlike `node.rotation`, motion rotation is not normalized to -180..180, so use `-360` for one full clockwise turn *on top of* the resting transform. |
| `SCALE_X`, `SCALE_Y`, `SCALE_XY` | `FLOAT` or `VECTOR` multiplier | multiplicative (neutral `1` or `{ x: 1, y: 1 }`) | `1` = no scale change. `0.5` halves the node's current scale; `2` doubles it. |
**Absolute fields — replace the node's value within the animation window.**
| Field | Value | Notes |
|---|---|---|
| `OPACITY` | `FLOAT` 0-1 | `0` transparent, `1` fully opaque, matching `node.opacity`. |
| `CORNER_RADIUS` | `FLOAT` pixels | `0` is no rounding. |
| `RECTANGLE_TOP_LEFT_CORNER_RADIUS`, `RECTANGLE_TOP_RIGHT_CORNER_RADIUS`, `RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS`, `RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS` | `FLOAT` pixels | Per-corner radius fields for rectangles. |
| `STROKE_WEIGHT`, `BORDER_TOP_WEIGHT`, `BORDER_BOTTOM_WEIGHT`, `BORDER_LEFT_WEIGHT`, `BORDER_RIGHT_WEIGHT` | `FLOAT` pixels | Stroke/border weights. |
| `STACK_SPACING`, `STACK_COUNTER_SPACING`, `STACK_PADDING_LEFT`, `STACK_PADDING_TOP`, `STACK_PADDING_RIGHT`, `STACK_PADDING_BOTTOM`, `GRID_ROW_GAP`, `GRID_COLUMN_GAP` | `FLOAT` pixels | Layout spacing and padding fields. |
| `PATH_TRIM_START`, `PATH_TRIM_END` | `FLOAT` 0-1 | Path trim start/end. |
| `WIDTH`, `HEIGHT` | `FLOAT` pixels | Must be positive. Not valid for groups and vector shapes. |
Fill color animation uses `manualKeyframeTracks.fills` by paint index; stroke color animation uses `manualKeyframeTracks.strokes`, and effect animation uses `manualKeyframeTracks.effects`. See the indexed keyframe sections below.
### Pre-first and post-last keyframe behaviour
The first keyframe's value **holds back to `t=0`** — the field stays at that value from the start of the timeline until the first keyframe is reached. The last keyframe's value holds forward to the end of the timeline. You don't need a redundant `t=0` keyframe just to "pin" the resting state.
```js
// Track starts animating at t=2s. No explicit t=0 keyframe needed —
// OPACITY holds at 0 from t=0 until the first keyframe.
{
OPACITY: {
keyframes: [
{ timelinePosition: 2, value: { type: "FLOAT", value: 0 } },
{ timelinePosition: 2.5, value: { type: "FLOAT", value: 1 }, easing: { type: "EASE_OUT" } },
],
},
}
```
This also means the easing on the first keyframe is unused (there's no previous keyframe to ease from).
### Add A Translation Track
```js
const node = await figma.getNodeByIdAsync("123:456");
const existing = node.manualKeyframeTracks ?? {};
const duration = 1;
const mutatedNodeIds = [node.id];
node.manualKeyframeTracks = {
...existing,
TRANSLATION_X: {
keyframes: [
{
timelinePosition: 0,
easing: { type: "LINEAR" },
value: { type: "FLOAT", value: 0 },
},
{
timelinePosition: duration,
easing: { type: "EASE_OUT" },
value: { type: "FLOAT", value: 120 },
},
],
},
};
const [timeline] = node.timelines;
if (timeline && timeline.duration < duration) {
node.setTimelineDuration(timeline.id, duration);
mutatedNodeIds.push(timeline.id);
}
return {
mutatedNodeIds,
manualKeyframeTracks: node.manualKeyframeTracks,
timelines: node.timelines,
};
```
Prefer `applyManualKeyframeTrack` when adding or replacing a single track; it avoids reconstructing unrelated tracks:
```js
const node = await figma.getNodeByIdAsync("123:456");
const duration = 1;
const mutatedNodeIds = [node.id];
node.applyManualKeyframeTrack(
{ type: "PROPERTY", name: "TRANSLATION_X" },
{
keyframes: [
{ timelinePosition: 0, value: { type: "FLOAT", value: 0 } },
{ timelinePosition: duration, easing: { type: "EASE_OUT" }, value: { type: "FLOAT", value: 120 } },
],
},
);
const [timeline] = node.timelines;
if (timeline && timeline.duration < duration) {
node.setTimelineDuration(timeline.id, duration);
mutatedNodeIds.push(timeline.id);
}
return {
mutatedNodeIds,
manualKeyframeTracks: node.manualKeyframeTracks,
timelines: node.timelines,
};
```
### Edit An Existing Track
When editing existing manual keyframes, preserve track `id` values and keyframe `id` values for anything that should continue to exist.
```js
const node = await figma.getNodeByIdAsync("123:456");
const existing = node.manualKeyframeTracks ?? {};
const opacityTrack = existing.OPACITY;
if (!opacityTrack) {
throw new Error("Node has no OPACITY manual keyframe track.");
}
node.manualKeyframeTracks = {
...existing,
OPACITY: {
...opacityTrack,
keyframes: opacityTrack.keyframes.map((keyframe, index) =>
index === 1
? { ...keyframe, value: { type: "FLOAT", value: 0.75 } }
: keyframe,
),
},
};
return {
mutatedNodeIds: [node.id],
manualKeyframeTracks: node.manualKeyframeTracks,
};
```
### Remove Tracks
Remove one track by omitting it when writing the object back:
```js
const node = await figma.getNodeByIdAsync("123:456");
const { ROTATION, ...rest } = node.manualKeyframeTracks ?? {};
node.manualKeyframeTracks = rest;
return {
mutatedNodeIds: [node.id],
removedTrack: "ROTATION",
manualKeyframeTracks: node.manualKeyframeTracks,
};
```
Or remove one track with the helper:
```js
const node = await figma.getNodeByIdAsync("123:456");
node.removeManualKeyframeTrack({ type: "PROPERTY", name: "ROTATION" });
return {
mutatedNodeIds: [node.id],
manualKeyframeTracks: node.manualKeyframeTracks,
};
```
Remove all manual tracks:
```js
const node = await figma.getNodeByIdAsync("123:456");
node.manualKeyframeTracks = {};
return {
mutatedNodeIds: [node.id],
manualKeyframeTracks: node.manualKeyframeTracks,
};
```
## Fill and Stroke Color Keyframes
Fill color animation is stored in `manualKeyframeTracks.fills`, keyed by the zero-based paint index in `node.fills`. Stroke color animation works the same way via `manualKeyframeTracks.strokes`, keyed by index in `node.strokes`. Only `SOLID` paints can be animated this way.
Color keyframe values use `{ r, g, b, a }` in the `0-1` range. All examples below use `fills`; the `strokes` shape is identical — substitute `strokes` for `fills` and `node.strokes` for `node.fills`.
### Read Animated Fills
```js
const node = await figma.getNodeByIdAsync("123:456");
return {
id: node.id,
fills: node.fills,
animatedFills: node.manualKeyframeTracks?.fills ?? {},
};
```
### Write A Fill Color Track
Always ensure the target fill index exists and is a solid fill.
```js
const node = await figma.getNodeByIdAsync("123:456");
const mutatedNodeIds = [node.id];
node.fills = [
{ type: "SOLID", color: { r: 1, g: 0, b: 0 } },
];
const existing = node.manualKeyframeTracks ?? {};
const duration = 1;
node.manualKeyframeTracks = {
...existing,
fills: {
...(existing.fills ?? {}),
0: {
keyframes: [
{
timelinePosition: 0,
easing: { type: "LINEAR" },
value: { type: "COLOR", value: { r: 1, g: 0, b: 0, a: 1 } },
},
{
timelinePosition: duration,
easing: { type: "LINEAR" },
value: { type: "COLOR", value: { r: 0, g: 0, b: 1, a: 1 } },
},
],
},
},
};
const [timeline] = node.timelines;
if (timeline && timeline.duration < duration) {
node.setTimelineDuration(timeline.id, duration);
mutatedNodeIds.push(timeline.id);
}
return {
mutatedNodeIds,
animatedFills: node.manualKeyframeTracks.fills,
timelines: node.timelines,
};
```
### Edit A Fill Color Track
Preserve existing track and keyframe IDs:
```js
const node = await figma.getNodeByIdAsync("123:456");
const existing = node.manualKeyframeTracks ?? {};
const fill0 = existing.fills?.[0];
if (!fill0) {
throw new Error("Node has no animated fill at paint index 0.");
}
node.manualKeyframeTracks = {
...existing,
fills: {
...existing.fills,
0: {
...fill0,
keyframes: fill0.keyframes.map((keyframe, index) =>
index === 1
? { ...keyframe, value: { type: "COLOR", value: { r: 0, g: 0, b: 1, a: 1 } } }
: keyframe,
),
},
},
};
return {
mutatedNodeIds: [node.id],
animatedFills: node.manualKeyframeTracks.fills,
};
```
## Effect Keyframes
Effect animation is stored in `manualKeyframeTracks.effects`, keyed first by the zero-based effect index in `node.effects`, then by effect field name.
For blur within a native text animation, use `TextStagger`'s `blurEnabled`, `blurVisible`, and `blurAmount` instead; no static layer-blur effect is needed. See [text-animation.md](text-animation.md).
- Always ensure the target effect index exists before writing an effect track.
- Allowed effect fields are `OFFSET_X`, `OFFSET_Y`, `RADIUS`, `SPREAD`, `COLOR`, `REFRACTION_RADIUS`, `SPECULAR_ANGLE`, `SPECULAR_INTENSITY`, `CHROMATIC_ABERRATION`, `SPLAY`, `REFRACTION_INTENSITY`, `START_RADIUS`, `NOISE_SIZE_X`, `NOISE_SIZE_Y`, `DENSITY`, `EFFECT_OPACITY`, and `SECONDARY_COLOR`.
- Do not use generated effect fields that are not public Plugin API fields. `START_OFFSET_X`, `START_OFFSET_Y`, `END_OFFSET_X`, and `END_OFFSET_Y` intentionally throw.
- `COLOR` and `SECONDARY_COLOR` require `{ type: "COLOR", value: { r, g, b, a } }`.
- All other effect fields require `{ type: "FLOAT", value }`.
- Normalized effect fields (`SPECULAR_INTENSITY`, `CHROMATIC_ABERRATION`, `SPLAY`, `REFRACTION_INTENSITY`) use public `0-1` values.
```js
const node = await figma.getNodeByIdAsync("123:456");
node.applyManualKeyframeTrack(
{ type: "INDEXED_ITEM", collection: "effects", index: 0, field: "RADIUS" },
{
keyframes: [
{ timelinePosition: 0, value: { type: "FLOAT", value: 0 } },
{ timelinePosition: 0.4, easing: { type: "EASE_OUT" }, value: { type: "FLOAT", value: 24 } },
],
},
);
return {
mutatedNodeIds: [node.id],
animatedEffects: node.manualKeyframeTracks.effects,
};
```
## Animation Styles
Animation styles are reusable animations applied through `node.animationStyles`. A node can have multiple styles.
This section covers Figma's **first-party** style definitions. For reusable styles authored in the file itself — creating, editing, organizing, or applying a local `CustomAnimationStyle` — load [motion-custom-styles.md](motion-custom-styles.md) as well.
Style entries have:
- `styleId`: style definition ID, from `figma.motion.figmaAnimationStyles()`.
- `name`: required display name.
- `duration`: top-level duration in seconds for this style.
- `timelineOffset`: top-level start time in seconds.
- `props`: style-specific properties such as `timing`, `moveX`, `fade`, or `easing`. The available props vary per style — inspect the discovered definition's `props` before writing.
`timelineOffset` is a top-level field. Direct preset application accepts top-level `duration`, but a value or variable binding in `props.duration` takes precedence. Preserve existing bindings when editing. For `TextStagger`, duration covers the whole stagger; see [text-animation.md](text-animation.md).
### Discover Styles
```js
const styles = figma.motion.figmaAnimationStyles();
return styles.map((style) => ({
styleId: style.styleId,
name: style.name,
description: style.description,
props: style.props,
}));
```
The `props` field returns **documentation strings**, not setter shapes. For example, the Fade style reports its easing default as `"easing // default: { easingType: OUT_CUBIC }"` — that string is a signature hint, not a value to copy. When writing easing on a style's `props`, always use the public `{ type: 'EASE_OUT' }` form (see [motion-easing.md](motion-easing.md)).
### Apply A Style
```js
const node = await figma.getNodeByIdAsync("123:456");
const styles = figma.motion.figmaAnimationStyles();
const fadeDef = styles.find((style) => style.styleId === "Opacity");
const mutatedNodeIds = [node.id];
if (!fadeDef) {
throw new Error("Could not find the Fade animation style.");
}
node.animationStyles = [
...(node.animationStyles ?? []),
{
styleId: fadeDef.styleId,
name: fadeDef.name,
duration: 0.5,
timelineOffset: 0,
props: {
timing: "in",
easing: { type: "EASE_OUT" },
},
},
];
const styleEnd = 0 + 0.5;
const [timeline] = node.timelines;
if (timeline && timeline.duration < styleEnd) {
node.setTimelineDuration(timeline.id, styleEnd);
mutatedNodeIds.push(timeline.id);
}
return {
mutatedNodeIds,
animationStyles: node.animationStyles,
timelines: node.timelines,
};
```
Prefer `applyAnimationStyle` when adding a style. It returns the applied style instance `id`, which is the value to pass to `removeAnimationStyle` later:
```js
const node = await figma.getNodeByIdAsync("123:456");
const styles = figma.motion.figmaAnimationStyles();
const fadeDef = styles.find((style) => style.styleId === "Opacity");
const mutatedNodeIds = [node.id];
if (!fadeDef) {
throw new Error("Could not find the Fade animation style.");
}
const appliedStyleId = node.applyAnimationStyle(fadeDef.styleId, {
duration: 0.5,
timelineOffset: 0,
props: {
timing: "in",
easing: { type: "EASE_OUT" },
},
});
const [timeline] = node.timelines;
if (timeline && timeline.duration < 0.5) {
node.setTimelineDuration(timeline.id, 0.5);
mutatedNodeIds.push(timeline.id);
}
return {
mutatedNodeIds,
appliedStyleId,
animationStyles: node.animationStyles,
timelines: node.timelines,
};
```
### Edit Styles
Overwrite `node.animationStyles` with a mapped array:
```js
const node = await figma.getNodeByIdAsync("123:456");
node.animationStyles = (node.animationStyles ?? []).map((style) =>
style.name === "Fade"
? { ...style, duration: 0.35 }
: style,
);
return {
mutatedNodeIds: [node.id],
animationStyles: node.animationStyles,
};
```
### Remove Styles
```js
const node = await figma.getNodeByIdAsync("123:456");
node.animationStyles = (node.animationStyles ?? []).filter(
(style) => style.name !== "Fade",
);
return {
mutatedNodeIds: [node.id],
animationStyles: node.animationStyles,
};
```
When you have the applied style instance `id`, use the helper:
```js
const node = await figma.getNodeByIdAsync("123:456");
const fade = (node.animationStyles ?? []).find((style) => style.name === "Fade");
if (!fade?.id) {
throw new Error("Node has no applied Fade style instance.");
}
node.removeAnimationStyle(fade.id);
return {
mutatedNodeIds: [node.id],
animationStyles: node.animationStyles,
};
```
## Timeline Duration
`node.timelines` returns the containing top-level frame timeline as `{ id, duration }`, where `duration` is in seconds. `node.setTimelineDuration(id, durationSeconds)` writes that containing timeline; the `id` must match the timeline returned by the same node.
```js
const node = await figma.getNodeByIdAsync("123:456");
const requiredDuration = 3;
const [timeline] = node.timelines;
const mutatedNodeIds = [];
if (timeline && timeline.duration < requiredDuration) {
node.setTimelineDuration(timeline.id, requiredDuration);
mutatedNodeIds.push(timeline.id);
}
return {
mutatedNodeIds,
timelines: node.timelines,
};
```
## Transform Notes
Motion transform values are relative to the node's own coordinate system.
- Translation values are pixels.
- Rotation values are degrees.
- Scale values are multipliers.
- `ROTATION` and `SCALE_X` / `SCALE_Y` / `SCALE_XY` pivot around the node's visual centre by default.
## Motion Design Defaults
Unless the user asks for a strong effect, keep motion subtle:
- Prefer short durations around `0.25s` to `0.7s`.
- Stagger related elements instead of animating everything at once.
- Use sequence to show hierarchy: header, subheader, primary content, secondary content.
- Prefer `EASE_OUT`, `EASE_IN_AND_OUT`, `GENTLE`, or `QUICK` for polished UI motion.
- Avoid flashy loops, large rotations, and excessive bounce unless requested.
references/text-animation.md
# Text animation
Use this reference for motion within a text layer: character, word, line, or paragraph reveals, entrances, exits, and edits to the styles inside a text animation. For moving or fading the entire text layer together, ordinary node keyframes or animation styles are sufficient.
These examples run through `use_figma`: use top-level `await` and `return`, include `figma-use,figma-use-motion` in `skillNames`, and replace page/node/application IDs with inspected IDs. Each call has fresh JavaScript state and starts on the first page, so resolve the target and switch to its page in each call.
## Discover and preserve
1. Inspect the target text node, its containing timeline, and existing `animationStyles` before writing. Keep its characters, formatting, wrapping, and hierarchy intact. Native text animation uses the existing text layout; do not split text into separate nodes, outline glyphs, or replace characters to imitate a reveal.
2. If the request also changes characters or typography, make those static edits first. Before those edits, await `figma.loadFontAsync(fontName)` for each font from `node.getStyledTextSegments(['fontName'])`. A pure motion edit leaves the text and fonts intact.
3. Discover the native definition with `figma.motion.figmaAnimationStyles()` and match `styleId === 'TextStagger'`. Names may be localized. Read its advertised properties; discovery returns documentation strings, not values to copy into setters.
Text animation follows the main skill's node restrictions. Animate a text descendant of a top-level frame. Preserve component instances and instance sublayers; do not detach them to bypass an API error. The internal `Text` typewriter preset is not the discoverable `TextStagger` definition.
## One preset, multiple text styles
One applied `TextStagger` contains any combination of Position, Rotation, Scale, Opacity, and Blur. These are fields in one flat `props` object, not a nested array of independently applied styles.
For each style, `*Enabled: true` adds its tracks. `*Visible: false` keeps the style present but disables its tracks; `*Visible: true` enables playback. To remove only one style from the text animation, set its `*Enabled` field to `false`. To temporarily mute it, keep `*Enabled: true` and set `*Visible: false`.
| Style | Include / play | Settings and units |
| --- | --- | --- |
| Position | `positionEnabled` / `positionVisible` | `direction`: `left`, `right`, `top`, `bottom`, `top_left`, `top_right`, `bottom_left`, `bottom_right`; `distance` in pixels |
| Rotation | `rotationEnabled` / `rotationVisible` | `rotationDirection`: `clockwise` or `counterClockwise`; `rotationAmount` in degrees |
| Scale | `scaleEnabled` / `scaleVisible` | `scaleAmount` in percent; `100` is unchanged size |
| Opacity | `opacityEnabled` / `opacityVisible` | `opacityAmount` in percent of the resting opacity; `0` is transparent, `100` leaves the existing opacity unchanged |
| Blur | `blurEnabled` / `blurVisible` | `blurAmount` in pixels |
Position is included by default; the other four styles are not. All visibility controls default to `true`. Explicitly disable Position for an opacity-only or blur-only animation.
Shared properties apply to every included style:
- `animationDirection`: `'in'` animates from the configured amounts to the resting appearance; `'out'` reverses that motion. Position's `direction` describes the offset side: `'bottom'` with `'in'` starts below and moves up.
- `textDelayGrouping`: `'CHARACTER'`, `'CHARACTER_NO_WHITESPACE'`, `'WORD'`, `'LINE'`, or `'PARAGRAPH'`. Character grouping follows shaped text clusters; line grouping follows the current visual layout. Preserve wrapping and let the native renderer resolve groups.
- `textDelayOrder`: `'LEFT_RIGHT'`, `'RIGHT_LEFT'`, `'CENTER_OUT'`, or `'RANDOM'`.
- `duration`: the total text stagger span in seconds, not a duration to multiply by the number of words or characters. For direct application, it can be supplied as a top-level field; a value or variable binding in `props.duration` takes precedence.
- `presetStaggerOverlap`: `0` to `100` percent. Each group's animation duration is this fraction of the total duration; the remaining time distributes the groups' starts according to the chosen order. `100` animates all groups simultaneously; `0` makes each group's change instantaneous at its staggered start. It is not a delay in milliseconds.
- `delay`: seconds inside `props`; `timelineOffset`: a top-level start offset in seconds. Account for both when fitting the containing timeline.
- `easing`: use the public shape, such as `{ type: 'EASE_OUT' }`. See [motion-easing.md](motion-easing.md); never copy an internal enum from a discovery documentation string.
There is one setting per style type and shared timing/easing for the whole preset. Do not invent per-style durations, a nested `styles` array, or duplicate Position rows inside one preset. Separate entrance and exit phases can use separate `TextStagger` applications; preserve and identify each application independently.
## Add Position, Opacity, and Blur
This example adds one text animation with three styles. Replace the node id with the inspected target; adjust grouping and values to the request.
```js
const page = await figma.getNodeByIdAsync('0:1');
if (!page || page.type !== 'PAGE') throw new Error('Expected the inspected page');
await figma.setCurrentPageAsync(page);
const node = await figma.getNodeByIdAsync('123:456');
if (!node || node.type !== 'TEXT') {
throw new Error('Expected a text node');
}
const definition = figma.motion.figmaAnimationStyles().find(
(style) => style.styleId === 'TextStagger',
);
if (!definition) {
throw new Error('Text motion is unavailable in this session');
}
const timeline = node.timelines[0];
const timelineRoot = timeline && await figma.getNodeByIdAsync(timeline.id);
if (!timelineRoot) {
throw new Error('Expected text inside an animation timeline');
}
for (const segment of node.getStyledTextSegments(['fontName'])) {
await figma.loadFontAsync(segment.fontName);
}
const duration = 0.6;
const appliedId = node.applyAnimationStyle(definition.styleId, {
duration,
timelineOffset: 0,
props: {
animationDirection: 'in',
textDelayGrouping: 'WORD',
textDelayOrder: 'LEFT_RIGHT',
presetStaggerOverlap: 50,
delay: 0,
easing: { type: 'EASE_OUT' },
positionEnabled: true,
positionVisible: true,
direction: 'bottom',
distance: 24,
opacityEnabled: true,
opacityVisible: true,
opacityAmount: 0,
blurEnabled: true,
blurVisible: true,
blurAmount: 12,
rotationEnabled: false,
scaleEnabled: false,
},
});
// This example starts at zero with no delay; its total span is duration.
const mutatedNodeIds = [node.id];
if (timeline.duration < duration) {
node.setTimelineDuration(timeline.id, duration);
mutatedNodeIds.push(timeline.id);
}
return {
mutatedNodeIds,
appliedId,
applied: node.animationStyles.find((style) => style.id === appliedId),
timelines: node.timelines,
};
```
`applyAnimationStyle` adds a new application and preserves the node's existing animation. Keep the returned applied `id` for later edits or removal. The definition's `styleId` identifies the preset type, not a particular application.
## Typewriter and other starting points
Configure the same discovered `TextStagger` for these requests. Keep timing in seconds and adjust the total duration to the text and requested pace.
| Request | Configuration |
| --- | --- |
| Typewriter reveal | `animationDirection: 'in'`, `textDelayGrouping: 'CHARACTER'`, `textDelayOrder: 'LEFT_RIGHT'`, `presetStaggerOverlap: 0`, and only Opacity enabled/visible with `opacityAmount: 0`. Each character appears instantly at its staggered start; disable Position, Rotation, Scale, and Blur. Use `'CHARACTER_NO_WHITESPACE'` to skip spaces in the stagger. |
| Words rise and sharpen | The three-style example above: `'WORD'`, Position from `'bottom'`, Opacity from `0`, Blur to sharp, and nonzero overlap. |
| Lines fade in | `'LINE'`, only Opacity enabled/visible with `opacityAmount: 0`, and nonzero overlap. Wrapping determines the visual lines. |
| Paragraphs leave in reverse order | `'PARAGRAPH'`, `'RIGHT_LEFT'`, `animationDirection: 'out'`, and the requested enabled styles. |
| Characters expand from the center | `'CHARACTER'`, `'CENTER_OUT'`, Scale enabled/visible with `scaleAmount` below `100`, optionally with Opacity. Disable Position unless requested. |
A typewriter reveal preserves the final text layout; it does not reflow the sentence as if typing or create a blinking cursor. Do not repeatedly rewrite `characters`, use timers, or create one node per letter. The internal `Text` preset is not a substitute for the discoverable `TextStagger`.
## Edit or remove existing text motion
Select the intended application from the inspected `node.animationStyles` and retain its `id`. If there are multiple text animations, use their properties, phase, and start offsets to identify the requested one. Do not assume the applied `styleId` must still be the literal `'TextStagger'`: it can identify a backing component. Preserve the read-back identifiers and name.
For example, change the grouping and mute Blur on the previously identified application, preserving all other styles and properties. This is a separate `use_figma` call: replace `appliedId` with the literal id returned by the earlier call.
```js
const page = await figma.getNodeByIdAsync('0:1');
if (!page || page.type !== 'PAGE') throw new Error('Expected the inspected page');
await figma.setCurrentPageAsync(page);
const node = await figma.getNodeByIdAsync('123:456');
if (!node || node.type !== 'TEXT') throw new Error('Expected a text node');
const appliedId = '123:789'; // The identified application's id, not its styleId.
const existing = node.animationStyles.find((style) => style.id === appliedId);
if (!existing || existing.type !== 'FIGMA') {
throw new Error('Expected the identified Figma text animation');
}
for (const segment of node.getStyledTextSegments(['fontName'])) {
await figma.loadFontAsync(segment.fontName);
}
node.animationStyles = node.animationStyles.map((style) =>
style.id === appliedId
? {
...style,
props: {
...style.props,
textDelayGrouping: 'CHARACTER_NO_WHITESPACE',
blurVisible: false,
},
}
: style,
);
return {
mutatedNodeIds: [node.id],
applied: node.animationStyles.find((style) => style.id === appliedId),
timelines: node.timelines,
};
```
To remove Blur rather than mute it, set `blurEnabled: false`. To remove the entire identified text animation, call `node.removeAnimationStyle(appliedId)`. Preserve unrelated manual tracks and other applied animations.
A `type: 'CUSTOM'` binding points to a reusable style and does not expose editable `props` on the node. Do not convert it to a direct Figma preset or overwrite its definition as a side effect of a local text edit. The example above deliberately checks for a direct `FIGMA` application.
SKILL.md
---
name: figma-use-motion
description: "Motion / animation context for the `use_figma` MCP tool — animating Figma nodes via manual keyframes, animation styles, easing, and timeline duration. Load alongside figma-use whenever a task involves adding, editing, or inspecting animation on a node. Also covers working with custom animation styles."
disable-model-invocation: false
---
# use_figma — Figma Plugin API Skill for Motion
Motion context for the `use_figma` MCP tool. [figma-use](../figma-use/SKILL.md) covers the foundational Plugin API rules — load both together.
**Always pass `skillNames: "figma-use-motion"` (comma-separated alongside `figma-use`) when calling `use_figma` for motion work.** Logging only.
## Runtime Gating
Motion APIs might not be enabled for the calling user. When unavailable, every motion property and helper referenced in this skill throws `"<name>" is not a supported API`.
**Bail out fast on that error.** Do not retry; tell the user motion isn't enabled for them and stop. Otherwise you'll burn calls and confuse the user with repeated identical failures.
Custom animation styles might not be enabled even when other motion APIs work. See [motion-custom-styles.md](references/motion-custom-styles.md) for availability checks and fallback guidance.
Discover `TextStagger` through `figma.motion.figmaAnimationStyles()` before applying it. If it is absent, explain that text motion is unavailable in this session; do not bypass discovery or silently substitute whole-layer motion.
## When to use this skill
Load this skill whenever a `use_figma` task involves:
- Adding, editing, or removing keyframes on a node (`manualKeyframeTracks`, `applyManualKeyframeTrack`, `removeManualKeyframeTrack`).
- Animating fill or stroke colors over time.
- Applying, editing, or removing animation styles (`applyAnimationStyle`, `removeAnimationStyle`, `animationStyles`).
- Discovering, creating, editing, organizing, applying, or removing **local custom animation styles** — reusable named motion styles saved in the file (`figma.getLocalCustomAnimationStylesAsync`, `figma.createCustomAnimationStyle`).
- Animating characters, words, lines, or paragraphs within editable text, including typewriter reveals and edits to the styles inside a text animation. Load [text-animation.md](references/text-animation.md) before writing.
- Reading or writing timeline duration via `node.timelines` / `node.setTimelineDuration(id, seconds)`.
- Choosing easing for any of the above.
Static design work (creating shapes, components, variables, layout) goes through [figma-use](../figma-use/SKILL.md) alone — this skill is only for the time dimension.
## Exposed motion API surface
- `node.manualKeyframeTracks` — read/write manual keyframes (including fill, stroke, and effect tracks).
- `node.applyManualKeyframeTrack(field, track)` / `node.removeManualKeyframeTrack(field)` — add, replace, or remove one manual keyframe track without rewriting the whole object.
- `node.animationStyles` — read/write animation-style metadata applied to a node.
- `node.applyAnimationStyle(styleId, presetData?)` / `node.removeAnimationStyle(id)` — apply a discovered style and remove an applied style instance by its returned/read-back `id`.
- `node.timelines` — read-only timeline list for the containing top-level frame, with durations in seconds.
- `node.setTimelineDuration(id, durationSeconds)` — write the containing top-level frame timeline duration.
- `node.animations` — read-only resolved keyframe data (currently manual tracks only — see [motion-patterns.md](references/motion-patterns.md)).
- `figma.motion.figmaAnimationStyles()` — read-only list of Figma's first-party animation style definitions.
- Various APIs available for working with custom animation styles, see [motion-custom-styles.md](references/motion-custom-styles.md)
Authoring custom `"figma:motion"` preset module source code is out of scope. Configuring the Position, Rotation, Scale, Opacity, and Blur styles inside a discovered `TextStagger` is supported; these are properties of one native preset, not new preset source code. If the request requires authoring a new preset module, explain that limitation; don't fabricate one.
## Reference docs
Load these as needed based on what the task involves:
| Doc | When to load | What it covers |
|-----|-------------|----------------|
| [motion-patterns.md](references/motion-patterns.md) | Adding/editing motion animation | Manual keyframes, animated fills/strokes, applying animation styles, timeline duration |
| [motion-custom-styles.md](references/motion-custom-styles.md) | Any work on reusable named motion styles | Discovering, creating, editing, organizing, applying, removing, and verifying local custom animation styles |
| [text-animation.md](references/text-animation.md) | Creating/editing text reveals, typewriter effects, or staggered text | Native `TextStagger`, grouping/order, combined text styles, timing, edits/removal, preserving editable text |
| [motion-easing.md](references/motion-easing.md) | Setting animation easing | Keyframe easing objects, custom cubic/spring, `HOLD`, applying easing inside an animation style |
## Verifying the animation
`get_screenshot` shows only the timeline's **resting state**, never motion. To check motion, `export_video` and sample frames — but it renders server-side and is **slow and expensive (~10s to minutes)**, so make each render count.
**Plan before rendering — cost scales with pixels × frames, so keep both no larger than the frames need:**
1. **Pick the moments first.** You need one frame per *phase* (e.g. per stagger step, or start / mid / settle), not smooth playback — usually 4–6. This count sets your fps.
2. **Size to what you must read.** Start small — `constraint: { type: 'WIDTH', value: 320 }`, `quality: "low"` — but text and small elements blur there, so raise `WIDTH` (768+) when you need to judge fine detail. Omitting `constraint` = full size (1x; server clamps to 10x / 4096px).
3. **Set fps just high enough to land those frames:** `fps: 5` covers a handful; 10 is an upper bound. Higher just bloats the render.
**Mechanics:** `export_video` works only on a **top-level frame** whose children carry the animation (pass that frame, not the descendant you keyframed). It returns a `jobId` with `status: "processing"` — re-invoke with `{ fileKey, jobId }` to poll. Then extract frames locally with `ffmpeg -ss <t> -i anim.mp4 -frames:v 1 frame_<t>.png` — extraction is free, so once you've paid for the render, mine it for every frame that tells you something rather than re-exporting. Without a frame extractor like `ffmpeg`, skip the export and reason about the keyframes instead.
**Iterate until it's right.** The export is a diagnostic, not a sign-off: if the frames are wrong (bad order, off timing, a missing element, a mask blanking the composite), fix the keyframes/styles and re-export. Read *all* the frames and batch every fix into one pass before re-rendering — every render carries real overhead, so make each one count instead of re-exporting after each small change.
Skip the export entirely for trivial or self-evident changes.
## Pre-flight checklist
In addition to the [figma-use pre-flight checklist](../figma-use/SKILL.md#8-pre-flight-checklist), verify:
- [ ] Easing uses the public `{ type: 'EASE_OUT', easingFunctionCubicBezier?: …, easingFunctionSpring?: … }` shape — not internal scenegraph names like `OUT_CUBIC`.
- [ ] Ease-in-out uses the exact public enum `EASE_IN_AND_OUT` (or `EASE_IN_AND_OUT_BACK`); never emit the invalid alias `EASE_IN_OUT`.
- [ ] The node being animated is not a top-level frame (direct child of a page). Animate descendants instead.
- [ ] Timeline values are seconds in the public Plugin API. Extend via `setTimelineDuration`; never shorten unless the user asked.
- [ ] Within-text motion uses discovered `TextStagger` on the existing text node. Preserve characters, formatting, wrapping, and unrelated animation; its `duration` is the total stagger span, not per-character time.
- [ ] Transform keyframe fields use public names (`TRANSLATION_X`, `TRANSLATION_Y`, `ROTATION`, `SCALE_X`, `SCALE_Y`, `SCALE_XY`), not internal `MOTION_*` scenegraph names.
- [ ] Manual keyframe fields come from the public allowlist in [motion-patterns.md](references/motion-patterns.md#animatable-fields); generated/internal scenegraph fields intentionally throw.
- [ ] Mutated node IDs are returned (per `figma-use` Rule 15).
- [ ] When motion correctness isn't self-evident and a frame extractor (`ffmpeg`) is available, verify via `export_video` + frame sampling — render small, low `fps`, iterate until right (see the Verifying the animation section above). `get_screenshot` shows only the resting state.