Generate motion specification annotations from After Effects timeline data. Use when the user mentions "motion", "motion spec", "animation spec", "timeline", or wants to document a component's animation properties.
86
83%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Generate a motion specification directly in Figma — timeline bars and detail tables documenting all animated properties from an After Effects composition.
Read uspecs.config.json → mcpProvider. Follow the matching column for every MCP call in this skill.
| Operation | figma-console | figma-mcp |
|---|---|---|
| Verify connection | figma_get_status | Skip — implicit. If first use_figma call fails, guide user to check MCP setup. |
| Navigate to file | figma_navigate with URL | Extract fileKey from URL (figma.com/design/:fileKey/...). No navigate needed. |
| Take screenshot | figma_take_screenshot | get_screenshot with fileKey + nodeId |
| Execute Plugin JS | figma_execute with code | use_figma with fileKey, code, description. JS code is identical — no wrapper changes. |
| Search components | figma_search_components | search_design_system with query + fileKey + includeComponents: true |
| Get file/component data | figma_get_file_data / figma_get_component | get_metadata or get_design_context with fileKey + nodeId |
| Get variables (file-wide) | figma_get_variables | use_figma script: return await figma.variables.getLocalVariableCollectionsAsync(); |
| Get token values | figma_get_token_values | use_figma script reading variable values per mode/collection |
| Get styles | figma_get_styles | search_design_system with includeStyles: true, or use_figma: return figma.getLocalPaintStyles(); |
| Get selection | figma_get_selection | use_figma script: return figma.currentPage.selection.map(n => ({id: n.id, name: n.name, type: n.type})); |
figma-mcp requires fileKey on every call. Extract it once from the user's Figma URL at the start of the workflow. For branch URLs (figma.com/design/:fileKey/branch/:branchKey/:fileName), use :branchKey as the fileKey.
figma-mcp page context: use_figma resets figma.currentPage to the first page on every call. When a script accesses a node from a previous step via getNodeByIdAsync(ID), the page content may not be loaded — findAll, findOne, and characters will fail with TypeError until the page is activated. Insert this page-loading block immediately after getNodeByIdAsync:
let _p = node; while (_p.parent && _p.parent.type !== 'DOCUMENT') _p = _p.parent;
if (_p.type === 'PAGE') await figma.setCurrentPageAsync(_p);This walks up to the PAGE ancestor and loads its content. Console MCP does not need this — figma_execute inherits the Desktop page context.
The user triggers this skill with one of these patterns:
@create-motion + pasted JSON (from clipboard, exported via motion/export-timeline.jsx)@create-motion + a file reference (e.g., @motion-ex.json or a path to a .json file)@create-motion + a Figma link (destination page/file where the annotation should be placed)Required: The exported JSON data — either pasted inline or referenced as a file.
Optional:
Copy this checklist and update as you progress:
Task Progress:
- [ ] Step 1: Read instruction file
- [ ] Step 2: Verify MCP connection
- [ ] Step 3: Read template key from uspecs.config.json
- [ ] Step 4: Gather context (JSON from paste, file reference, or prompt user)
- [ ] Step 5: Parse and validate JSON (including segments[] and hasAnimatedSegments)
- [ ] Step 6: Transform data (read pre-computed segments, compute track width, timeline positions)
- [ ] Step 7: Re-read instruction file (Errors and Anti-Patterns, Validation Checklist) and audit
- [ ] Step 8: Import and detach the Motion template
- [ ] Step 9: Fill header fields
- [ ] Step 10: Generate time ruler
- [ ] Step 11: Render timeline layers (one figma_execute per layer)
- [ ] Step 12: Hide templates
- [ ] Step 13: Render table rows
- [ ] Step 14: Visual validationRead agent-motion-instruction.md
Read mcpProvider from uspecs.config.json to determine which Figma MCP to use.
If figma-console:
figma_get_status — Confirm Desktop Bridge plugin is activeIf figma-mcp:
use_figma call. No explicit check needed.Read the file uspecs.config.json and extract:
motionSpec value from the templateKeys object → save as MOTION_TEMPLATE_KEYIf the template key is empty, tell the user:
The motion spec template key is not configured. Run
@firstrunwith your Figma template library link first.
Check what the user provided:
@motion-ex.json) → read it and proceed directly to Step 5. Do not ask for it again.Please paste the exported JSON or reference the
.jsonfile (e.g.,@motion-ex.json).
Please paste the exported JSON from
export-timeline.jsx, or reference the.jsonfile.
If a Figma destination link was provided, use figma_navigate to open it before rendering (Step 8).
Also accept any optional screenshots, recordings, or descriptions the user provides for additional context.
Validate the clipboard JSON against the schema and validation rules in the instruction file (JSON Schema and JSON Validation Rules sections). If validation fails, tell the user exactly which field is missing or malformed and ask them to re-export.
The export script pre-computes segments, value formatting, bar labels, easing labels, and no-change filtering. The agent reads these directly and only computes layout-dependent values.
6a. Skip non-animated layers — Check layer.hasAnimatedSegments. If false, omit the layer from timeline and table.
6b. Read pre-computed segments — Each property has a segments[] array with startMs, endMs, durationMs, fromValue, toValue, barLabel, easing, and easingType. Use these values directly — no manual computation needed.
6c. Compute track width and pxPerMs — pxPerMs is a fixed rate; trackWidth is dynamic with right padding for tick labels:
compDurationMs = composition.durationMs // read directly from JSON
pxPerMs = 0.64 // fixed rate — ~320px per 500ms tick
trackWidth = max(compDurationMs * pxPerMs + 50, 1600) // data area + 50px label padding, min 1600Pass trackWidth and pxPerMs to the Figma rendering code. pxPerMs is a fixed rate (0.64), not derived from trackWidth / compDurationMs. Bar positions (barX, barWidth) are computed inside the Figma code at render time — the agent does not compute per-segment positions.
At render time, resize both #track-area and #ruler-track to this computed trackWidth. The parent containers use HUG sizing and expand automatically.
6d. Format composition meta per the instruction file's Composition Meta rules. Save as COMPOSITION_META.
Re-read the instruction file, focusing on:
Check your transformed data against each rule. Fix any violations.
Run via figma_execute (replace __MOTION_TEMPLATE_KEY__ and __COMPONENT_NAME__):
const TEMPLATE_KEY = '__MOTION_TEMPLATE_KEY__';
const templateComponent = await figma.importComponentByKeyAsync(TEMPLATE_KEY);
const instance = templateComponent.createInstance();
const { x, y } = figma.viewport.center;
instance.x = x - instance.width / 2;
instance.y = y - instance.height / 2;
const frame = instance.detachInstance();
frame.name = '__COMPONENT_NAME__ Motion';
figma.currentPage.selection = [frame];
figma.viewport.scrollAndZoomIntoView([frame]);
return { frameId: frame.id };Save the returned frameId — you need it for all subsequent steps.
Run via figma_execute (replace __FRAME_ID__, __COMPONENT_NAME__, __COMPOSITION_META__):
const FRAME_ID = '__FRAME_ID__';
const frame = await figma.getNodeByIdAsync(FRAME_ID);
const textNodes = frame.findAll(n => n.type === 'TEXT');
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of textNodes) {
try {
const fn = tn.fontName;
if (fn && fn !== figma.mixed && fn.family) {
const key = fn.family + '|' + fn.style;
if (!fontSet.has(key)) { fontSet.add(key); fontsToLoad.push(fn); }
}
} catch {}
}
await Promise.all(fontsToLoad.map(f => figma.loadFontAsync(f).catch(() => {})));
const compName = frame.findOne(n => n.name === '#component-name');
if (compName) {
const t = compName.type === 'TEXT' ? compName : compName.findOne(n => n.type === 'TEXT');
if (t) t.characters = '__COMPONENT_NAME__';
}
const compDesc = frame.findOne(n => n.name === '#component-description');
if (compDesc) {
const t = compDesc.type === 'TEXT' ? compDesc : compDesc.findOne(n => n.type === 'TEXT');
if (t) t.characters = 'Motion Specification';
}
const compMeta = frame.findOne(n => n.name === '#composition-meta');
if (compMeta) {
const t = compMeta.type === 'TEXT' ? compMeta : compMeta.findOne(n => n.type === 'TEXT');
if (t) t.characters = '__COMPOSITION_META__';
}
return { success: true };Run via figma_execute. Compute the ideal track width, resize #ruler-track and all #track-area nodes, then clone #tick for each interval.
Replace __FRAME_ID__, __COMP_DURATION_MS__ (read from composition.durationMs), __TRACK_WIDTH__ (computed in Step 6c), and __PX_PER_MS__ (fixed at 0.64):
const FRAME_ID = '__FRAME_ID__';
const COMP_DURATION_MS = __COMP_DURATION_MS__;
const TRACK_WIDTH = __TRACK_WIDTH__;
const PX_PER_MS = __PX_PER_MS__;
const frame = await figma.getNodeByIdAsync(FRAME_ID);
const rulerTrack = frame.findOne(n => n.name === '#ruler-track');
if (!rulerTrack) return { error: '#ruler-track not found' };
// Resize ruler track to computed width
rulerTrack.resize(TRACK_WIDTH, rulerTrack.height);
// Resize all #track-area nodes (in layer template and any cloned layers)
const trackAreas = frame.findAll(n => n.name === '#track-area');
for (const ta of trackAreas) {
ta.resize(TRACK_WIDTH, ta.height);
}
// Find the #tick template inside the ruler track
const tickTemplate = rulerTrack.findOne(n => n.name === '#tick');
if (!tickTemplate) return { error: '#tick template not found in #ruler-track' };
// Load fonts from the tick template's text nodes
const tickTextNodes = tickTemplate.findAll(n => n.type === 'TEXT');
for (const tn of tickTextNodes) {
if (tn.characters.length > 0) {
try { await figma.loadFontAsync(tn.fontName); } catch {}
}
}
// Tick interval: aim for 6–12 ticks
const tickInterval = COMP_DURATION_MS <= 300 ? 50
: COMP_DURATION_MS <= 600 ? 100
: COMP_DURATION_MS <= 1500 ? 250
: COMP_DURATION_MS <= 4000 ? 500
: COMP_DURATION_MS <= 10000 ? 1000
: 2000;
for (let ms = 0; ms <= COMP_DURATION_MS; ms += tickInterval) {
const xPos = ms * PX_PER_MS;
const tick = tickTemplate.clone();
rulerTrack.appendChild(tick);
tick.name = ms + 'ms';
tick.visible = true;
tick.x = xPos;
const label = tick.findOne(n => n.type === 'TEXT');
if (label) {
try { await figma.loadFontAsync(label.fontName); } catch {}
label.characters = ms + 'ms';
}
}
tickTemplate.visible = false;
return { success: true, trackWidth: TRACK_WIDTH, tickInterval: tickInterval };Run one figma_execute call per layer to avoid timeouts. For each layer (where hasAnimatedSegments is true), clone the #layer-template, set the layer name, then for each animated property clone #property-template and position bars.
Replace all __PLACEHOLDER__ values. __PROPERTIES_JSON__ is an array where each entry has name and segments[] — each segment with startMs, durationMs, easingType, and barLabel (straight from the JSON). The Figma code computes bar positions at render time using PX_PER_MS, so the agent does not pre-compute barX/barWidth.
const FRAME_ID = '__FRAME_ID__';
const LAYER_NAME = '__LAYER_NAME__';
const PX_PER_MS = __PX_PER_MS__;
const TRACK_WIDTH = __TRACK_WIDTH__;
const PROPERTIES = __PROPERTIES_JSON__;
const frame = await figma.getNodeByIdAsync(FRAME_ID);
const layerTemplate = frame.findOne(n => n.name === '#layer-template');
if (!layerTemplate) return { error: '#layer-template not found' };
const layer = layerTemplate.clone();
layerTemplate.parent.appendChild(layer);
layer.name = LAYER_NAME;
layer.visible = true;
const textNodes = layer.findAll(n => n.type === 'TEXT');
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of textNodes) {
try {
const fn = tn.fontName;
if (fn && fn !== figma.mixed && fn.family) {
const key = fn.family + '|' + fn.style;
if (!fontSet.has(key)) { fontSet.add(key); fontsToLoad.push(fn); }
}
} catch {}
}
await Promise.all(fontsToLoad.map(f => figma.loadFontAsync(f).catch(() => {})));
// Read legend colors directly from color nodes
const colorBezier = frame.findOne(n => n.name === '#color-bezier');
const colorLinear = frame.findOne(n => n.name === '#color-linear');
const colorHold = frame.findOne(n => n.name === '#color-hold');
function getNodeColor(node) {
if (!node) return { r: 0.5, g: 0.5, b: 0.5 };
if (node.fills && node.fills.length > 0 && node.fills[0].color) {
return node.fills[0].color;
}
return { r: 0.5, g: 0.5, b: 0.5 };
}
const colors = {
BEZIER: getNodeColor(colorBezier),
LINEAR: getNodeColor(colorLinear),
HOLD: getNodeColor(colorHold)
};
const layerNameNode = layer.findOne(n => n.name === '#layer-name');
if (layerNameNode) {
const t = layerNameNode.type === 'TEXT' ? layerNameNode : layerNameNode.findOne(n => n.type === 'TEXT');
if (t) t.characters = LAYER_NAME;
}
const propertyTemplate = layer.findOne(n => n.name === '#property-template');
if (!propertyTemplate) return { error: '#property-template not found in layer' };
for (let p = 0; p < PROPERTIES.length; p++) {
const prop = PROPERTIES[p];
const propNode = propertyTemplate.clone();
propertyTemplate.parent.appendChild(propNode);
propNode.name = prop.name;
propNode.visible = true;
// Load fonts for cloned text nodes
const propTextNodes = propNode.findAll(n => n.type === 'TEXT');
for (const tn of propTextNodes) {
if (tn.characters.length > 0) {
try { await figma.loadFontAsync(tn.fontName); } catch {}
}
}
const propNameNode = propNode.findOne(n => n.name === '#property-name');
if (propNameNode) {
const t = propNameNode.type === 'TEXT' ? propNameNode : propNameNode.findOne(n => n.type === 'TEXT');
if (t) t.characters = prop.name;
}
// Render bars in the track area
const trackArea = propNode.findOne(n => n.name === '#track-area');
const barTemplate = propNode.findOne(n => n.name === '#bar-template');
if (trackArea && barTemplate) {
for (let s = 0; s < prop.segments.length; s++) {
const seg = prop.segments[s];
let barX = seg.startMs * PX_PER_MS;
let barW = Math.max(seg.durationMs * PX_PER_MS, 4);
if (barX + barW > TRACK_WIDTH) barW = TRACK_WIDTH - barX;
const bar = barTemplate.clone();
trackArea.appendChild(bar);
bar.name = 'bar-' + s;
bar.visible = true;
bar.x = barX;
bar.y = 0;
bar.resize(barW, bar.height);
bar.fills = [{ type: 'SOLID', color: colors[seg.easingType] || colors.LINEAR }];
const barLabel = bar.findOne(n => n.name === '#bar-label');
if (barLabel) {
const t = barLabel.type === 'TEXT' ? barLabel : barLabel.findOne(n => n.type === 'TEXT');
if (t) {
try { await figma.loadFontAsync(t.fontName); } catch {}
t.characters = seg.barLabel;
}
}
}
barTemplate.visible = false;
}
}
propertyTemplate.visible = false;
return { success: true, layer: LAYER_NAME, propertyCount: PROPERTIES.length };After all layers are rendered, hide the original #layer-template:
const frame = await figma.getNodeByIdAsync('__FRAME_ID__');
const layerTemplate = frame.findOne(n => n.name === '#layer-template');
if (layerTemplate) layerTemplate.visible = false;
return { success: true };Run one figma_execute call to clone #table-row-template for each segment. If there are more than 15 rows, split into multiple calls.
Replace __FRAME_ID__ and __ROWS_JSON__. Each row has: element (layer name), property (property name), from (segment.fromValue), to (segment.toValue), duration (segment.durationMs + "ms"), delay (segment.startMs + "ms"), easing (segment.easing).
Only include rows from layers where hasAnimatedSegments is true.
const FRAME_ID = '__FRAME_ID__';
const ROWS = __ROWS_JSON__;
const frame = await figma.getNodeByIdAsync(FRAME_ID);
const rowTemplate = frame.findOne(n => n.name === '#table-row-template');
if (!rowTemplate) return { error: '#table-row-template not found' };
const textNodes = rowTemplate.findAll(n => n.type === 'TEXT');
const fontSet = new Set();
const fontsToLoad = [];
for (const tn of textNodes) {
try {
const fn = tn.fontName;
if (fn && fn !== figma.mixed && fn.family) {
const key = fn.family + '|' + fn.style;
if (!fontSet.has(key)) { fontSet.add(key); fontsToLoad.push(fn); }
}
} catch {}
}
await Promise.all(fontsToLoad.map(f => figma.loadFontAsync(f).catch(() => {})));
for (let i = 0; i < ROWS.length; i++) {
const row = ROWS[i];
const rowNode = rowTemplate.clone();
rowTemplate.parent.appendChild(rowNode);
rowNode.name = 'Row ' + i;
rowNode.visible = true;
const fields = {
'#cell-element': row.element,
'#cell-property': row.property,
'#cell-from': row.from,
'#cell-to': row.to,
'#cell-duration': row.duration,
'#cell-delay': row.delay,
'#cell-easing': row.easing
};
for (const [selector, value] of Object.entries(fields)) {
const cell = rowNode.findOne(n => n.name === selector);
if (cell) {
const t = cell.type === 'TEXT' ? cell : cell.findOne(n => n.type === 'TEXT');
if (t) t.characters = value;
}
}
}
rowTemplate.visible = false;
return { success: true, rowCount: ROWS.length };figma_take_screenshot with the frameId — Capture the completed annotationhasAnimatedSegments: true are present in the timelinehasAnimatedSegments: false are absentsegments[])figma_execute and re-capture (up to 3 iterations)Print a clickable Figma URL to the completed spec in chat. Construct the URL from the fileKey (extracted from the user's destination URL, or the current file if no destination was provided) and the frameId (returned by Step 8), replacing : with - in the node ID:
Motion spec complete: https://www.figma.com/design/{fileKey}/?node-id={frameId}uspecs.config.json under templateKeys.motionSpec and is configured via @firstrun.#property-template, #bar-template, and #table-row-template may be hidden in the template by default. Clones must set .visible = true; originals are hidden after cloning.figma_execute splitting strategy is: 1 call for import+detach, 1 for header, 1 for time ruler, 1 per layer for timeline, 1 for hiding templates, and 1 (or more) for table rows.b1213ef
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.