Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Official Figma skill for writing directly to the Figma canvas through the MCP server and Plugin API.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
gotchas.md
1# Gotchas & Common Mistakes23> Part of the [use_figma skill](../SKILL.md). Every known pitfall with WRONG/CORRECT code examples.45## Contents67- Component properties and variant creation pitfalls8- Paint, color, and variable binding pitfalls9- Page context and plugin lifecycle pitfalls10- Auto Layout and sizing order pitfalls (including HUG/FILL interactions)11- Variant layout and geometry pitfalls12- Variable scopes and mode pitfalls13- Node cleanup and empty-fill pitfalls14- detachInstance() and node ID invalidation151617## New nodes default to (0,0) and overlap existing content1819Every `figma.create*()` call places the node at position (0,0). If you append multiple nodes directly to the page, they all stack on top of each other and on top of any existing content.2021**This only matters for nodes appended directly to the page** (i.e., top-level nodes). Nodes appended as children of other frames, components, or auto-layout containers are positioned by their parent — don't scan for overlaps when nesting nodes.2223```js24// WRONG — top-level node lands at (0,0), overlapping existing page content25const frame = figma.createFrame()26frame.name = "My New Frame"27frame.resize(400, 300)28figma.currentPage.appendChild(frame)2930// CORRECT — find existing content bounds and place the new top-level node to the right31const page = figma.currentPage32let maxX = 033for (const child of page.children) {34const right = child.x + child.width35if (right > maxX) maxX = right36}37const frame = figma.createFrame()38frame.name = "My New Frame"39frame.resize(400, 300)40figma.currentPage.appendChild(frame)41frame.x = maxX + 100 // 100px gap from rightmost existing content42frame.y = 04344// NOT NEEDED — child nodes inside a parent don't need overlap scanning45const card = figma.createFrame()46card.layoutMode = 'VERTICAL'47const label = figma.createText()48card.appendChild(label) // positioned by auto-layout, no x/y needed49```5051## `addComponentProperty` returns a string key, not an object — never hardcode or guess it5253Figma generates the property key dynamically (e.g. `"label#4:0"`). The suffix is unpredictable. Always capture and use the return value directly.5455```js56// WRONG — guessing / hardcoding the key57comp.addComponentProperty('label', 'TEXT', 'Button')58labelNode.componentPropertyReferences = { characters: 'label#0:1' } // Error: key not found5960// WRONG — treating the return value as an object61const result = comp.addComponentProperty('Label', 'TEXT', 'Button')62const propKey = Object.keys(result)[0] // BUG: returns '0' (first char index of string!)63labelNode.componentPropertyReferences = { characters: propKey } // Error: property '0' not found6465// CORRECT — the return value IS the key string, use it directly66const propKey = comp.addComponentProperty('Label', 'TEXT', 'Button')67// propKey === "label#4:0" (exact value varies; never assume it)68labelNode.componentPropertyReferences = { characters: propKey }69```7071The same applies to `COMPONENT_SET` nodes — `addComponentProperty` always returns the property key as a string.7273## MUST return ALL created/mutated node IDs7475Every script that creates or mutates nodes on the canvas must track and return all affected node IDs in the return value. Without these IDs, subsequent calls cannot reference, validate, or clean up those nodes.7677```js78// WRONG — only returns the parent frame ID, loses track of children79const frame = figma.createFrame()80const rect = figma.createRectangle()81const text = figma.createText()82frame.appendChild(rect)83frame.appendChild(text)84return { nodeId: frame.id }8586// CORRECT — returns all created node IDs in a structured response87const frame = figma.createFrame()88const rect = figma.createRectangle()89const text = figma.createText()90frame.appendChild(rect)91frame.appendChild(text)92return {93createdNodeIds: [frame.id, rect.id, text.id],94rootNodeId: frame.id95}9697// CORRECT — when mutating existing nodes, return those IDs too98const nodes = figma.currentPage.findAll(n => n.name === 'Card')99for (const n of nodes) {100n.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }]101}102return {103mutatedNodeIds: nodes.map(n => n.id),104count: nodes.length105}106```107108## Colors are 0–1 range109110```js111// WRONG — will throw validation error (ZeroToOne enforced)112node.fills = [{ type: 'SOLID', color: { r: 255, g: 0, b: 0 } }]113114// CORRECT115node.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }]116```117118## Fills/strokes are immutable arrays119120```js121// WRONG — modifying in place does nothing122node.fills[0].color = { r: 1, g: 0, b: 0 }123124// CORRECT — clone, modify, reassign125const fills = JSON.parse(JSON.stringify(node.fills))126fills[0].color = { r: 1, g: 0, b: 0 }127node.fills = fills128```129130## setBoundVariableForPaint returns a NEW paint131132```js133// WRONG — ignoring return value134figma.variables.setBoundVariableForPaint(paint, "color", colorVar)135node.fills = [paint] // paint is unchanged!136137// CORRECT — capture the returned new paint138const boundPaint = figma.variables.setBoundVariableForPaint(paint, "color", colorVar)139node.fills = [boundPaint]140```141142## Variable collection starts with 1 mode143144```js145// A new collection already has one mode — rename it, don't try to add first146const collection = figma.variables.createVariableCollection("Colors")147// collection.modes = [{ modeId: "...", name: "Mode 1" }]148collection.renameMode(collection.modes[0].modeId, "Light")149const darkModeId = collection.addMode("Dark")150```151152## combineAsVariants requires ComponentNodes153154```js155// WRONG — passing frames156const f1 = figma.createFrame()157figma.combineAsVariants([f1], figma.currentPage) // Error!158159// CORRECT — passing components160const c1 = figma.createComponent()161c1.name = "variant=primary, size=md"162const c2 = figma.createComponent()163c2.name = "variant=secondary, size=md"164figma.combineAsVariants([c1, c2], figma.currentPage)165```166167## Page switching: sync setter throws168169The sync setter `figma.currentPage = page` **throws an error** in `use_figma` runtimes (MCP, evals, assistant). Use `await figma.setCurrentPageAsync(page)` instead — it switches the page and loads its content.170171```js172// WRONG — throws "Setting figma.currentPage is not supported in this runtime"173figma.currentPage = targetPage174175// CORRECT — async method switches and loads content176await figma.setCurrentPageAsync(targetPage)177```178179## `get_metadata` only sees one page — use `use_figma` to discover all pages180181A Figma file can have multiple pages (canvas nodes). `get_metadata` operates on a single node/page — it cannot scan the entire document. To discover all pages and their top-level contents, use `use_figma`:182183```js184// WRONG — calling get_metadata with the file root or expecting it to list all pages185// get_metadata only returns the subtree of the node you pass it186187// CORRECT — use use_figma to list pages, then inspect each one188const pages = figma.root.children.map(p => `${p.name} id=${p.id} children=${p.children.length}`);189return pages.join('\n');190```191192Icons, variables, and components may live on pages other than the first. Always enumerate all pages before concluding that the file has no existing assets.193194## Never use figma.notify()195196```js197// WRONG — throws "not implemented" error198figma.notify("Done!")199200// CORRECT — return a value to send data back to the agent201return "Done!"202```203204## `getPluginData()` / `setPluginData()` are not supported205206These APIs are not available in the `use_figma` runtime. Use `getSharedPluginData()` / `setSharedPluginData()` instead (these ARE supported), or track nodes by returning IDs.207208```js209// WRONG — not supported in use_figma210node.setPluginData('my_key', 'my_value')211const val = node.getPluginData('my_key')212213// CORRECT — use shared plugin data (requires a namespace)214node.setSharedPluginData('my_namespace', 'my_key', 'my_value')215const val = node.getSharedPluginData('my_namespace', 'my_key')216217// ALSO CORRECT — return node IDs and track them across calls218const rect = figma.createRectangle()219return { nodeId: rect.id }220// Then pass nodeId as a string literal in the next use_figma call221```222223## Script must always return a value224225```js226// WRONG — no return, caller gets no useful response227figma.createRectangle()228229// CORRECT — return a result (objects are auto-serialized, errors are auto-captured)230const rect = figma.createRectangle()231return { nodeId: rect.id }232```233234## setBoundVariable for paint fields only works on SOLID paints235236```js237// Only SOLID paint type supports color variable binding238// Gradient paints, image paints, etc. will throw239const solidPaint = { type: 'SOLID', color: { r: 0, g: 0, b: 0 } }240const bound = figma.variables.setBoundVariableForPaint(solidPaint, "color", colorVar)241```242243## Explicit variable modes must be set per component244245```js246// WRONG — all variants render with the default (first) mode247const colorCollection = figma.variables.createVariableCollection("Colors")248// ... create variables and modes ...249// Components all show the first mode's values by default!250251// CORRECT — set explicit mode on each component to get variant-specific values252component.setExplicitVariableModeForCollection(colorCollection, targetModeId)253```254255## `TextStyle.setBoundVariable` is not available in headless use_figma256257`setBoundVariable` exists on `TextStyle` in the typed API but is **not available** when running scripts through `use_figma` (MCP, headless assistant mode). Calling it will throw `"not a function"`.258259```js260// WRONG — throws "not a function" in use_figma / headless261const ts = figma.createTextStyle()262ts.setBoundVariable("fontSize", fontSizeVar)263264// CORRECT (headless) — set raw values; bind variables interactively in Figma later265const ts = figma.createTextStyle()266ts.fontSize = 24267```268269This only affects `TextStyle`. Variable binding on **nodes** (`node.setBoundVariable(...)`) and on **paint objects** (`figma.variables.setBoundVariableForPaint(...)`) still works in headless mode as expected.270271If live variable binding on text styles is required, create the styles with raw values via `use_figma`, then bind variables interactively through the Figma Styles panel or a full interactive plugin.272273## `lineHeight` and `letterSpacing` must be objects, not bare numbers274275```js276// WRONG — throws or silently does nothing277style.lineHeight = 1.5278style.lineHeight = 24279style.letterSpacing = 0280281// CORRECT282style.lineHeight = { unit: "AUTO" } // auto/intrinsic283style.lineHeight = { value: 24, unit: "PIXELS" } // fixed pixel height284style.lineHeight = { value: 150, unit: "PERCENT" } // percentage of font size285286style.letterSpacing = { value: 0, unit: "PIXELS" } // no tracking287style.letterSpacing = { value: -0.5, unit: "PIXELS" } // tight288style.letterSpacing = { value: 5, unit: "PERCENT" } // percent-based289```290291This applies to both `TextStyle` and `TextNode` properties. The same rule applies inside `use_figma`, interactive plugins, and any other plugin API context.292293## Font style names are file-dependent — probe before assuming294295Font style names vary per provider and per Figma file. `"SemiBold"` and `"Semi Bold"` are different strings. Loading a font with the wrong style string **throws silently or errors** — there is no canonical list.296297```js298// WRONG — guessing style names299await figma.loadFontAsync({ family: "Inter", style: "SemiBold" }) // may throw300301// CORRECT — probe which style names are available302const candidates = ["SemiBold", "Semi Bold", "Semibold"]303for (const style of candidates) {304try {305await figma.loadFontAsync({ family: "Inter", style })306// capture the one that works307break308} catch (_) {}309}310```311312When building a type ramp script, always verify font styles against the target file before hardcoding them.313314## combineAsVariants does NOT auto-layout in headless mode315316```js317// WRONG — all variants stack at position (0, 0), resulting in a tiny ComponentSet318const components = [comp1, comp2, comp3]319const cs = figma.combineAsVariants(components, figma.currentPage)320// cs.width/height will be the size of a SINGLE variant!321322// CORRECT — manually layout children in a grid after combining323const cs = figma.combineAsVariants(components, figma.currentPage)324const colWidth = 120325const rowHeight = 56326cs.children.forEach((child, i) => {327const col = i % numCols328const row = Math.floor(i / numCols)329child.x = col * colWidth330child.y = row * rowHeight331})332// CRITICAL: resize from actual child bounds, not formula — formula errors leave variants outside the boundary333let maxX = 0, maxY = 0334for (const child of cs.children) {335maxX = Math.max(maxX, child.x + child.width)336maxY = Math.max(maxY, child.y + child.height)337}338cs.resizeWithoutConstraints(maxX + 40, maxY + 40)339```340341## COLOR variable values use {r, g, b, a} (with alpha)342343```js344// Paint colors use {r, g, b} (no alpha — opacity is a separate paint property)345node.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }]346347// But COLOR variable values use {r, g, b, a} — alpha maps to paint opacity348const colorVar = figma.variables.createVariable("bg", collection, "COLOR")349colorVar.setValueForMode(modeId, { r: 1, g: 0, b: 0, a: 1 }) // opaque red350colorVar.setValueForMode(modeId, { r: 0, g: 0, b: 0, a: 0 }) // fully transparent351352// ⚠️ Don't confuse: {r, g, b} for paint colors vs {r, g, b, a} for variable values353```354355## `layoutSizingVertical`/`layoutSizingHorizontal` = `'FILL'` requires auto-layout parent FIRST356357```js358// WRONG — setting FILL before the node is a child of an auto-layout frame359const child = figma.createFrame()360child.layoutSizingVertical = 'FILL' // ERROR: "FILL can only be set on children of auto-layout frames"361parent.appendChild(child)362363// CORRECT — append to auto-layout parent FIRST, then set FILL364const child = figma.createFrame()365parent.appendChild(child) // parent must have layoutMode set366child.layoutSizingVertical = 'FILL' // Works!367```368369## HUG parents collapse FILL children370371A `HUG` parent cannot give `FILL` children meaningful size. If children have `layoutSizingHorizontal = "FILL"` but the parent is `"HUG"`, the children collapse to minimum size. The parent must be `"FILL"` or `"FIXED"` for FILL children to expand. This is a common cause of truncated text in select fields, inputs, and action rows.372373```js374// WRONG — parent hugs, so FILL children get zero extra space375const parent = figma.createFrame()376parent.layoutMode = 'HORIZONTAL'377parent.layoutSizingHorizontal = 'HUG'378const child = figma.createFrame()379parent.appendChild(child)380child.layoutSizingHorizontal = 'FILL' // collapses to min size!381382// CORRECT — parent must be FIXED or FILL for FILL children to expand383const parent = figma.createFrame()384parent.layoutMode = 'HORIZONTAL'385parent.resize(400, 50)386parent.layoutSizingHorizontal = 'FIXED' // or 'FILL' if inside another auto-layout387const child = figma.createFrame()388parent.appendChild(child)389child.layoutSizingHorizontal = 'FILL' // expands to fill remaining 400px390```391392## `layoutGrow` with a hugging parent causes content compression393394```js395// WRONG — layoutGrow on a child when parent has primaryAxisSizingMode='AUTO' (hug)396// causes the child to SHRINK below its natural size instead of expanding397const parent = figma.createComponent()398parent.layoutMode = 'VERTICAL'399parent.primaryAxisSizingMode = 'AUTO' // hug contents400const content = figma.createFrame()401content.layoutMode = 'VERTICAL'402content.primaryAxisSizingMode = 'AUTO'403parent.appendChild(content)404content.layoutGrow = 1 // BUG: content compresses, children hidden!405406// CORRECT — only use layoutGrow when parent has FIXED sizing with extra space407content.layoutGrow = 0 // let content take its natural size408// OR: set parent to FIXED sizing first409parent.primaryAxisSizingMode = 'FIXED'410parent.resizeWithoutConstraints(300, 500)411content.layoutGrow = 1 // NOW it correctly fills remaining space412```413414## `resize()` resets `primaryAxisSizingMode` and `counterAxisSizingMode` to FIXED415416`resize(w, h)` silently resets **both** sizing modes to `FIXED`. If you call it after setting `HUG`, the frame locks to the exact pixel value you passed — even a throwaway like `1`.417418```js419// WRONG — resize() after setting sizing mode overwrites it