Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Migrate design systems between Figma files, or from other tools to Figma via API. Use when migrating design tokens between files, syncing variables across libraries, or building automated migration pipelines for Figma. Trigger with phrases like "migrate figma", "figma migration", "move figma library", "figma file migration", "sync figma files".
.claude/skills/jeremylongshore-figma-migration-deep-dive/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-10 | ✗→✓ | ▲ Improved | 152% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 115% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 84% | 0% |
| case-06 | ✓→✓ | = Same ✓ | 119% | 0% |
| case-07 | ✓→✓ | = Same ✓ | 122% | 0% |
Automate migration of design data between Figma files, from other tools to Figma, or from Figma styles to the Variables API. Covers inventory, extraction, transformation, and validation.
FIGMA_PAT with file_content:read and file_variables:write (Enterprise) scopestypescriptconst PAT = process.env.FIGMA_PAT!; async function inventoryFile(fileKey: string) { const res = await fetch( `https://api.figma.com/v1/files/${fileKey}`, { headers: { 'X-Figma-Token': PAT } } ); const file = await res.json(); const inventory = { name: file.name, pages: file.document.children.map((p: any) => p.name), componentCount: Object.keys(file.components).length, styleCount: Object.keys(file.styles).length, styles: { fills: Object.values(file.styles).filter((s: any) => s.style_type === 'FILL').length, text: Object.values(file.styles).filter((s: any) => s.style_type === 'TEXT').length, effects: Object.values(file.styles).filter((s: any) => s.style_type === 'EFFECT').length, grids: Object.values(file.styles).filter((s: any) => s.style_type === 'GRID').length, }, }; // Count total nodes let nodeCount = 0; function countNodes(node: any) { nodeCount++; if (node.children) node.children.forEach(countNodes); } countNodes(file.document); (inventory as any).totalNodes = nodeCount; return inventory; } // Usage const inv = await inventoryFile(process.env.FIGMA_FILE_KEY!); console.log(`File: ${inv.name}`); console.log(`Pages: ${inv.pages.join(', ')}`); console.log(`Components: ${inv.componentCount}, Styles: ${inv.styleCount}`); console.log(`Total nodes: ${(inv as any).totalNodes}`);
typescriptasync function extractAllStyles(fileKey: string) { const file = await fetch( `https://api.figma.com/v1/files/${fileKey}`, { headers: { 'X-Figma-Token': PAT } } ).then(r => r.json()); const styleNodeIds = Object.keys(file.styles); const nodesRes = await fetch( `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${styleNodeIds.join(',')}`, { headers: { 'X-Figma-Token': PAT } } ).then(r => r.json()); const extracted = []; for (const [nodeId, styleMeta] of Object.entries(file.styles) as any[]) { const node = nodesRes.nodes[nodeId]?.document; if (!node) continue; extracted.push({ name: styleMeta.name, type: styleMeta.style_type, nodeId, data: { fills: node.fills, strokes: node.strokes, effects: node.effects, style: node.style, // typography characters: node.characters, }, }); } return extracted; }
typescript// Map extracted styles to design tokens JSON interface MigrationToken { name: string; category: 'color' | 'typography' | 'effect'; source: { file: string; nodeId: string }; value: any; } function transformStyles(styles: any[], sourceFileKey: string): MigrationToken[] { return styles.map(style => { switch (style.type) { case 'FILL': const fill = style.data.fills?.[0]; return { name: style.name, category: 'color' as const, source: { file: sourceFileKey, nodeId: style.nodeId }, value: fill?.color ? { r: Math.round(fill.color.r * 255), g: Math.round(fill.color.g * 255), b: Math.round(fill.color.b * 255), a: fill.color.a ?? 1, } : null, }; case 'TEXT': return { name: style.name, category: 'typography' as const, source: { file: sourceFileKey, nodeId: style.nodeId }, value: style.data.style ? { fontFamily: style.data.style.fontFamily, fontSize: style.data.style.fontSize, fontWeight: style.data.style.fontWeight, lineHeight: style.data.style.lineHeightPx, } : null, }; default: return { name: style.name, category: 'effect' as const, source: { file: sourceFileKey, nodeId: style.nodeId }, value: style.data.effects, }; } }).filter(t => t.value !== null); }
typescript// Enterprise only: create variables in the target file async function migrateToVariables( targetFileKey: string, tokens: MigrationToken[] ) { const colorTokens = tokens.filter(t => t.category === 'color'); // Create variable collection and variables const payload = { variableCollections: [{ action: 'CREATE' as const, id: 'temp_collection_1', name: 'Migrated Colors', }], variables: colorTokens.map((token, i) => ({ action: 'CREATE' as const, id: `temp_var_${i}`, name: token.name.replace(/\//g, '/'), // preserve Figma group paths variableCollectionId: 'temp_collection_1', resolvedType: 'COLOR' as const, codeSyntax: { WEB: `--${token.name.toLowerCase().replace(/[\s/]+/g, '-')}` }, })), variableModeValues: colorTokens.map((token, i) => ({ variableId: `temp_var_${i}`, modeId: '', // Will use default mode value: { r: token.value.r / 255, g: token.value.g / 255, b: token.value.b / 255, a: token.value.a, }, })), }; const res = await fetch( `https://api.figma.com/v1/files/${targetFileKey}/variables`, { method: 'POST', headers: { 'X-Figma-Token': PAT, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), } ); if (!res.ok) throw new Error(`Variable creation failed: ${res.status} ${await res.text()}`); return res.json(); }
typescriptasync function validateMigration( sourceFileKey: string, targetFileKey: string ): Promise<{ passed: boolean; issues: string[] }> { const sourceStyles = await extractAllStyles(sourceFileKey); const targetVars = await fetch( `https://api.figma.com/v1/files/${targetFileKey}/variables/local`, { headers: { 'X-Figma-Token': PAT } } ).then(r => r.json()); const issues: string[] = []; const targetNames = new Set( Object.values(targetVars.meta.variables).map((v: any) => v.name) ); for (const style of sourceStyles) { if (style.type === 'FILL' && !targetNames.has(style.name)) { issues.push(`Missing in target: ${style.name}`); } } return { passed: issues.length === 0, issues }; }
| Error | Cause | Solution | |-------|-------|----------| | 403 on Variables POST | Not Enterprise | Use JSON export instead of Variables API | | Duplicate variable names | Name collision in target | Add prefix/suffix to migrated names | | Missing node data | Node deleted between fetch and read | Re-fetch with error handling | | Large file timeout | File >100MB | Use /nodes endpoint for specific pages |
Dry-run a styles→variables migration and review the mapping before writing (Steps 2-3):
bashnode migrate.js --source ${SOURCE_FILE_KEY} --target ${TARGET_FILE_KEY} --dry-run
text48 styles found in source (32 FILL, 12 TEXT, 4 EFFECT) 32 FILL styles → color variables in collection "Primitives" Color/Brand/Primary → color/brand/primary #4F46E5 Color/Neutral/100 → color/neutral/100 #F5F5F5 2 styles skipped: gradient fills (no variable equivalent) — kept as styles DRY RUN — no POST to /v1/files/{key}/variables performed
Then re-run without --dry-run to write via the Variables API and validate with Step 5 (GET /v1/files/{key}/variables/local count check). Transform rules: references/transform-and-map-to-target.md.
For advanced troubleshooting, see figma-advanced-troubleshooting.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-03 | fail→fail | 11,341 | 9,546 | -16% | 1 | 1 | 0% | 2,369 | 4,536 | +91% | 0 | 0 | — |
case-04 | pass→pass | 9,910 | 6,683 | -33% | 1 | 1 | 0% | 1,731 | 3,725 | +115% | 0 | 0 | — |
case-05 | pass→pass | 12,885 | 4,457 | -65% | 1 | 1 | 0% | 1,803 | 3,317 | +84% | 0 | 0 | — |
case-06 | pass→pass | 14,552 | 12,256 | -16% | 1 | 1 | 0% | 2,267 | 4,960 | +119% | 0 | 0 | — |
case-07 | pass→pass | 12,828 | 13,915 | +8% | 1 | 1 | 0% | 2,416 | 5,368 | +122% | 0 | 0 | — |
case-08 | pass→pass | 6,380 | 4,178 | -35% | 1 | 1 | 0% | 1,125 | 3,364 | +199% | 0 | 0 | — |
case-09 | pass→pass | 5,459 | 3,444 | -37% | 1 | 1 | 0% | 944 | 3,212 | +240% | 0 | 0 | — |
case-10 | fail→pass | 8,020 | 5,550 | -31% | 1 | 1 | 0% | 1,327 | 3,343 | +152% | 0 | 0 | — |
case-11 | pass→pass | 9,477 | 10,050 | +6% | 1 | 1 | 0% | 1,606 | 4,498 | +180% | 0 | 0 | — |
case-12 | pass→pass | 15,420 | 249,185 | +1516% | 1 | 1 | 0% | 2,257 | 5,140 | +128% | 0 | 0 | — |
case-13 | pass→pass | 725,174 | 2,743 | -100% | 1 | 1 | 0% | 1,004 | 2,912 | +190% | 0 | 0 | — |
case-14 | pass→pass | 20,288 | 16,946 | -16% | 1 | 1 | 0% | 2,961 | 5,445 | +84% | 0 | 0 | — |
case-15 | pass→pass | 12,452 | 12,840 | +3% | 1 | 1 | 0% | 1,947 | 4,879 | +151% | 0 | 0 | — |
case-16 | pass→pass | 11,068 | 5,370 | -51% | 1 | 1 | 0% | 2,247 | 3,562 | +59% | 0 | 0 | — |
case-17 | pass→pass | 8,159 | 5,561 | -32% | 1 | 1 | 0% | 1,533 | 3,625 | +136% | 0 | 0 | — |
case-18 | pass→pass | 6,086 | 5,147 | -15% | 1 | 1 | 0% | 1,179 | 3,514 | +198% | 0 | 0 | — |
case-19 | pass→pass | 10,420 | 8,190 | -21% | 1 | 1 | 0% | 1,941 | 4,126 | +113% | 0 | 0 | — |
case-20 | pass→pass | 11,534 | 8,678 | -25% | 1 | 1 | 0% | 2,178 | 4,230 | +94% | 0 | 0 | — |
case-21 | pass→pass | 11,701 | 10,061 | -14% | 1 | 1 | 0% | 2,289 | 4,424 | +93% | 0 | 0 | — |
case-22 | pass→pass | 6,826 | 9,288 | +36% | 1 | 1 | 0% | 1,292 | 4,345 | +236% | 0 | 0 | — |
case-23 | pass→pass | 11,489 | 10,508 | -9% | 1 | 1 | 0% | 2,205 | 4,698 | +113% | 0 | 0 | — |
case-01 | fail→fail | 23,956 | 18,738 | -22% | 1 | 1 | 0% | 5,387 | 5,805 | +8% | 0 | 0 | — |
case-02 | fail→fail | 28,044 | 22,579 | -19% | 1 | 1 | 0% | 5,794 | 7,544 | +30% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +4 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.