38 KiB
Part of the figma-generate-library skill.
Component Creation Reference
Complete guide for Phase 3: building components with variant matrices, variable bindings, component properties, and documentation.
1. Component Architecture
Dependency Ordering: Atoms Before Molecules
Always build in dependency order. A molecule that contains an atom instance cannot exist until the atom is published. Suggested ordering:
Tier 0 (atoms): Icon, Avatar, Badge, Spinner
Tier 1 (molecules): Button, Checkbox, Toggle, Input, Select
Tier 2 (organisms): Card, Dialog, Menu, Navigation, Form
If a component embeds an instance of another component, the embedded component must be created first. Build your dependency graph during Phase 0 and encode the creation order in the plan.
Building Blocks Sub-Components (M3 Pattern)
For complex components with independent sub-element state machines, extract the sub-element into its own component set prefixed with Building Blocks/ (public) or .Building Blocks/ (hidden from assets panel). The dot-prefix is a Figma convention for suppressing a component from the public assets panel.
When to use Building Blocks:
- The sub-element has its own variant axes (state, selection) that would cause combinatorial explosion in the parent
- The sub-element repeats (nav items, table cells, calendar cells, segmented button segments)
- The sub-element has different variant axes than the parent
Example (M3 Segmented Button):
Building Blocks/Segmented button/Button segment (start) [27 variants: Config × State × Selected]
Building Blocks/Segmented button/Button segment (middle) [27 variants]
Building Blocks/Segmented button/Button segment (end) [27 variants]
Segmented button [16 variants: Segments=2-5 × Density=0/-1/-2/-3]
Each variant contains instances of the appropriate Building Block segment components.
The parent manages composition and configuration; the Building Block manages its own interaction states.
Private Components (__ Prefix)
Use the __ prefix for internal helper components that should not appear in the team library (Shop Minis pattern). Use _ for documentation-only components (UI3 pattern).
__asset // private icon/asset holder
_Label/Direction // documentation annotation helper
2. Creating the Component Page
Each component lives on its own dedicated page (one page per component is the default). The page contains: a documentation frame at top-left and the component set positioned to its right or below.
// Create or find the component page
let page = figma.root.children.find(p => p.name === 'Button');
if (!page) {
page = figma.createPage();
page.name = 'Button';
}
await figma.setCurrentPageAsync(page);
// Documentation frame — positioned at (40, 40)
const docFrame = figma.createFrame();
docFrame.name = 'Button / Documentation';
docFrame.x = 40;
docFrame.y = 40;
docFrame.resize(600, 400);
docFrame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
docFrame.layoutMode = 'VERTICAL';
docFrame.primaryAxisSizingMode = 'AUTO';
docFrame.counterAxisSizingMode = 'FIXED';
docFrame.paddingTop = 40;
docFrame.paddingBottom = 40;
docFrame.paddingLeft = 40;
docFrame.paddingRight = 40;
docFrame.itemSpacing = 16;
// Title text node
await figma.loadFontAsync({ family: 'Inter', style: 'Bold' });
const title = figma.createText();
title.fontName = { family: 'Inter', style: 'Bold' };
title.fontSize = 32;
title.characters = 'Button';
docFrame.appendChild(title);
// Description text node
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
const desc = figma.createText();
desc.fontName = { family: 'Inter', style: 'Regular' };
desc.fontSize = 14;
desc.characters = 'Buttons allow users to take actions and make choices with a single tap.';
docFrame.appendChild(desc);
// Tag docFrame with sharedPluginData for idempotency
docFrame.setSharedPluginData('dsb', 'run_id', RUN_ID);
docFrame.setSharedPluginData('dsb', 'key', 'doc/button');
return { docFrameId: docFrame.id, pageId: page.id };
3. Base Component: Auto-Layout, Child Nodes, Variable Bindings
The base component is the template from which all variants are cloned. It must have:
- Auto-layout (not manual positioning)
- All child nodes present
- ALL visual properties bound to variables (no hardcoded values)
Complete Button Base Component Example
const RUN_ID = 'ds-build-2024-001'; // replace with your actual run ID
await figma.setCurrentPageAsync(
figma.root.children.find(p => p.name === 'Button')
);
// Rehydrate variables from IDs stored in state ledger
const bgVar = await figma.variables.getVariableByIdAsync('VAR_ID_color_bg_primary');
const textVar = await figma.variables.getVariableByIdAsync('VAR_ID_color_text_on_primary');
const paddingVar = await figma.variables.getVariableByIdAsync('VAR_ID_spacing_md');
const radiusVar = await figma.variables.getVariableByIdAsync('VAR_ID_radius_md');
const gapVar = await figma.variables.getVariableByIdAsync('VAR_ID_spacing_sm');
// --- Base component frame ---
const comp = figma.createComponent();
comp.name = 'Size=Medium, Style=Primary, State=Default';
comp.layoutMode = 'HORIZONTAL';
comp.primaryAxisSizingMode = 'AUTO';
comp.counterAxisSizingMode = 'AUTO';
comp.counterAxisAlignItems = 'CENTER';
comp.primaryAxisAlignItems = 'CENTER';
// Padding — bound to spacing variables
comp.setBoundVariable('paddingTop', paddingVar);
comp.setBoundVariable('paddingBottom', paddingVar);
comp.setBoundVariable('paddingLeft', paddingVar);
comp.setBoundVariable('paddingRight', paddingVar);
comp.setBoundVariable('itemSpacing', gapVar);
// Corner radius — bound to radius variable
comp.setBoundVariable('topLeftRadius', radiusVar);
comp.setBoundVariable('topRightRadius', radiusVar);
comp.setBoundVariable('bottomLeftRadius', radiusVar);
comp.setBoundVariable('bottomRightRadius', radiusVar);
// Background fill — bound to color variable
const bgPaint = figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } },
'color',
bgVar
);
comp.fills = [bgPaint];
// --- Label text node ---
await figma.loadFontAsync({ family: 'Inter', style: 'Medium' });
const label = figma.createText();
label.name = 'label';
label.fontName = { family: 'Inter', style: 'Medium' };
label.fontSize = 14;
label.characters = 'Button';
label.layoutSizingHorizontal = 'HUG';
label.layoutSizingVertical = 'HUG';
// Text fill — bound to color variable
const textPaint = figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } },
'color',
textVar
);
label.fills = [textPaint];
comp.appendChild(label);
// --- Icon placeholder (Rectangle for now — will be INSTANCE_SWAP) ---
const iconBox = figma.createFrame();
iconBox.name = 'icon';
iconBox.resize(16, 16);
iconBox.fills = [];
iconBox.layoutSizingHorizontal = 'FIXED';
iconBox.layoutSizingVertical = 'FIXED';
comp.appendChild(iconBox);
// Tag for idempotency
comp.setSharedPluginData('dsb', 'run_id', RUN_ID);
comp.setSharedPluginData('dsb', 'phase', 'phase3');
comp.setSharedPluginData('dsb', 'key', 'component/button/base');
return { baseCompId: comp.id };
ALL of these must be variable-bound (never hardcoded):
| Property | Variable type | API method |
|---|---|---|
| Fill color | COLOR | setBoundVariableForPaint(..., 'color', var) |
| Stroke color | COLOR | setBoundVariableForPaint(..., 'color', var) |
| Text fill | COLOR | setBoundVariableForPaint(..., 'color', var) |
| Padding (all 4 sides) | FLOAT | comp.setBoundVariable('paddingTop', var) |
| Gap / itemSpacing | FLOAT | comp.setBoundVariable('itemSpacing', var) |
| Corner radius (all 4) | FLOAT | comp.setBoundVariable('topLeftRadius', var) etc. |
| Stroke weight | FLOAT | comp.setBoundVariable('strokeWeight', var) |
4. Variant Matrix
Defining Axes
For each component, identify its variant axes before writing any code. Standard axes:
Button:
Size → [Small, Medium, Large]
Style → [Primary, Secondary, Outline, Ghost]
State → [Default, Hover, Focused, Pressed, Disabled]
Total = 3 × 4 × 5 = 60 combinations — exceeds 30 limit → split by Style
The 30-Combination Cap and Split Strategy
When the product of all variant axes exceeds 30 combinations, split the matrix. Options:
- Split by a primary axis: Create separate component sets, one per Style (Primary Button, Secondary Button, etc.)
- Use INSTANCE_SWAP: Remove a visual axis (like Icon) from the variant matrix entirely and expose it as an INSTANCE_SWAP property instead
- Use Building Blocks: Extract sub-elements with their own state axes into Building Block component sets
For Button with Size × State = 15 combinations, add Style as a variant axis only if Style ≤ 2 options (15 × 2 = 30). For more Styles, split.
Creating All Variants with use_figma
Build each variant by cloning the base component and adjusting the variable bindings that differ per variant. Pass in the base component ID from the previous call's state.
const RUN_ID = 'ds-build-2024-001';
const BASE_COMP_ID = 'BASE_ID_FROM_STATE'; // from state ledger
await figma.setCurrentPageAsync(
figma.root.children.find(p => p.name === 'Button')
);
const base = await figma.getNodeByIdAsync(BASE_COMP_ID);
// Variable IDs from state ledger
const vars = {
// Primary style
bg_primary: await figma.variables.getVariableByIdAsync('VAR_ID_color_bg_primary'),
text_primary: await figma.variables.getVariableByIdAsync('VAR_ID_color_text_on_primary'),
// Secondary style
bg_secondary: await figma.variables.getVariableByIdAsync('VAR_ID_color_bg_secondary'),
text_secondary: await figma.variables.getVariableByIdAsync('VAR_ID_color_text_secondary'),
// Disabled
bg_disabled: await figma.variables.getVariableByIdAsync('VAR_ID_color_bg_disabled'),
text_disabled: await figma.variables.getVariableByIdAsync('VAR_ID_color_text_disabled'),
// Sizes
padding_sm: await figma.variables.getVariableByIdAsync('VAR_ID_spacing_sm'),
padding_md: await figma.variables.getVariableByIdAsync('VAR_ID_spacing_md'),
padding_lg: await figma.variables.getVariableByIdAsync('VAR_ID_spacing_lg'),
};
const axes = {
Size: ['Small', 'Medium', 'Large'],
Style: ['Primary', 'Secondary'],
State: ['Default', 'Hover', 'Disabled'],
};
const paddingBySize = { Small: vars.padding_sm, Medium: vars.padding_md, Large: vars.padding_lg };
const components = [];
for (const size of axes.Size) {
for (const style of axes.Style) {
for (const state of axes.State) {
const clone = base.clone();
clone.name = `Size=${size}, Style=${style}, State=${state}`;
// Bind padding by size
clone.setBoundVariable('paddingTop', paddingBySize[size]);
clone.setBoundVariable('paddingBottom', paddingBySize[size]);
clone.setBoundVariable('paddingLeft', paddingBySize[size]);
clone.setBoundVariable('paddingRight', paddingBySize[size]);
// Bind fill by style + state
const isDisabled = state === 'Disabled';
const bgVar = isDisabled ? vars.bg_disabled : (style === 'Primary' ? vars.bg_primary : vars.bg_secondary);
const txtVar = isDisabled ? vars.text_disabled : (style === 'Primary' ? vars.text_primary : vars.text_secondary);
const bgPaint = figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', bgVar
);
clone.fills = [bgPaint];
const labelNode = clone.findOne(n => n.name === 'label');
const textPaint = figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }, 'color', txtVar
);
labelNode.fills = [textPaint];
clone.setSharedPluginData('dsb', 'run_id', RUN_ID);
clone.setSharedPluginData('dsb', 'key', `component/button/variant/${size}/${style}/${state}`);
components.push(clone);
}
}
}
return { variantIds: components.map(c => c.id) };
5. combineAsVariants + Grid Layout
After all variant components exist, combine them into a ComponentSet and position them in a grid. This MUST be a separate use_figma call — you must pass in all variant IDs from the previous call's return value.
Grid Design Conventions
Professional design systems lay out variants in a readable grid where:
- Columns = the property users interact with most (typically State: Default, Hover, Focused, Pressed, Disabled)
- Rows = structural axes grouped together (typically Size × Style, where Size varies fastest)
- Gap = 16–40px between variants (20px is a safe default; match existing file if one exists)
- Padding = 40px around the grid inside the ComponentSet frame
Visual structure:
Default Hover Focused Pressed Disabled
┌──────────────────────────────────────────────────────────────────┐
│ Small/Primary [comp] [comp] [comp] [comp] [comp] │
│ Small/Secondary [comp] [comp] [comp] [comp] [comp] │
│ Medium/Primary [comp] [comp] [comp] [comp] [comp] │
│ Medium/Secondary[comp] [comp] [comp] [comp] [comp] │
│ Large/Primary [comp] [comp] [comp] [comp] [comp] │
│ Large/Secondary [comp] [comp] [comp] [comp] [comp] │
└──────────────────────────────────────────────────────────────────┘
Why State on columns? State is the axis designers scan horizontally to verify interaction consistency. Size/Style define the "identity" of each row. This matches how professional design systems (M3, Polaris, Simple DS) organize their grids.
Adding Row/Column Header Labels
After laying out the grid, add text labels OUTSIDE the ComponentSet to help navigation. These are siblings of the ComponentSet on the page — not children of it:
// Add column headers above the component set
const colLabels = ['Default', 'Hover', 'Focused', 'Pressed', 'Disabled'];
await figma.loadFontAsync({ family: 'Inter', style: 'Medium' });
for (let i = 0; i < colLabels.length; i++) {
const label = figma.createText();
label.fontName = { family: 'Inter', style: 'Medium' };
label.characters = colLabels[i];
label.fontSize = 11;
label.fills = [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }];
label.x = cs.x + padding + i * (childWidth + gap);
label.y = cs.y - 20;
}
// Add row headers to the left of the component set
const rowLabels = ['Small / Primary', 'Small / Secondary', 'Med / Primary', ...];
for (let i = 0; i < rowLabels.length; i++) {
const label = figma.createText();
label.fontName = { family: 'Inter', style: 'Medium' };
label.characters = rowLabels[i];
label.fontSize = 11;
label.fills = [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }];
label.x = cs.x - 120;
label.y = cs.y + padding + i * (childHeight + gap) + childHeight / 2 - 6;
}
Note: These labels are documentation aids, not part of the component itself. They help designers navigate the variant grid.
Grid layout code
const VARIANT_IDS = ['ID1', 'ID2', '...']; // from state ledger
const PAGE_ID = 'PAGE_ID'; // from state ledger
await figma.setCurrentPageAsync(await figma.getNodeByIdAsync(PAGE_ID));
// Collect component nodes
const components = await Promise.all(
VARIANT_IDS.map(id => figma.getNodeByIdAsync(id))
);
// Combine as variants
const cs = figma.combineAsVariants(components, figma.currentPage);
cs.name = 'Button';
// Grid layout: position each variant based on its property values
// Determine column axis (State) and row axes (Size × Style)
const axes = {
Size: ['Small', 'Medium', 'Large'],
Style: ['Primary', 'Secondary'],
State: ['Default', 'Hover', 'Disabled'],
};
const COL_AXIS = 'State'; // columns
const ROW_AXES = ['Size', 'Style']; // rows (Size changes fastest)
const gap = 16;
const padding = 40;
// Measure child dimensions (all should be same height within Size tier)
// Use the first child as reference for column width
const childWidth = 120; // approximate; refine after first screenshot
const childHeight = 40;
cs.children.forEach(child => {
const props = {};
child.name.split(', ').forEach(part => {
const [k, v] = part.split('=');
props[k] = v;
});
const colIdx = axes[COL_AXIS].indexOf(props[COL_AXIS]);
// Row = Size index * number of styles + Style index
const rowIdx = axes.Size.indexOf(props.Size) * axes.Style.length
+ axes.Style.indexOf(props.Style);
child.x = padding + colIdx * (childWidth + gap);
child.y = padding + rowIdx * (childHeight + gap);
});
// Resize component set to fit all children + padding
let maxX = 0, maxY = 0;
for (const child of cs.children) {
maxX = Math.max(maxX, child.x + child.width);
maxY = Math.max(maxY, child.y + child.height);
}
cs.resizeWithoutConstraints(maxX + padding, maxY + padding);
// Style the component set frame
cs.fills = [{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.98 } }];
cs.cornerRadius = 8;
// Position component set on page (to the right of doc frame)
cs.x = 680;
cs.y = 40;
cs.setSharedPluginData('dsb', 'run_id', 'ds-build-2024-001');
cs.setSharedPluginData('dsb', 'key', 'componentset/button');
return { componentSetId: cs.id };
Critical rules for combineAsVariants:
componentsmust be a non-empty array containing ONLYComponentNodeobjects (not frames, not groups)- After combining, children are placed at (0,0) and overlap — you MUST manually position them
resizeWithoutConstraintsis required after positioning to make the component set frame fit its contents- There is no
figma.createComponentSet()— you cannot create an empty component set
6. Component Properties
Add TEXT, BOOLEAN, and INSTANCE_SWAP properties to the ComponentSet (not to individual variants). The return value of addComponentProperty is the actual property key (it gets a #id:id suffix appended) — save this key and use it immediately when setting componentPropertyReferences.
TEXT Properties
Expose editable text in instances:
// On the ComponentSetNode (cs):
const labelKey = cs.addComponentProperty('Label', 'TEXT', 'Button');
// labelKey is now something like "Label#0:1"
// Wire to the label child in each variant:
for (const child of cs.children) {
const labelNode = child.findOne(n => n.name === 'label');
if (labelNode) {
labelNode.componentPropertyReferences = { characters: labelKey };
}
}
BOOLEAN Properties
Toggle child node visibility:
const showIconKey = cs.addComponentProperty('Show Icon', 'BOOLEAN', true);
for (const child of cs.children) {
const iconNode = child.findOne(n => n.name === 'icon');
if (iconNode) {
iconNode.componentPropertyReferences = { visible: showIconKey };
}
}
INSTANCE_SWAP Properties
Allow swapping a nested component instance (e.g., swap the icon):
// defaultIconCompId is the ID of the default icon component (from state ledger)
const iconKey = cs.addComponentProperty('Icon', 'INSTANCE_SWAP', DEFAULT_ICON_COMP_ID);
for (const child of cs.children) {
const iconSlot = child.findOne(n => n.name === 'icon');
if (iconSlot && iconSlot.type === 'INSTANCE') {
iconSlot.componentPropertyReferences = { mainComponent: iconKey };
}
}
Use INSTANCE_SWAP instead of creating a variant per icon. Never add "Icon=ChevronRight, Icon=ChevronLeft, ..." as VARIANT axes — that causes combinatorial explosion. One INSTANCE_SWAP property covers all icons.
Creating Icon Components for INSTANCE_SWAP
INSTANCE_SWAP needs a real Component ID as its default value. Before wiring INSTANCE_SWAP, you need at least one icon component. Here's how to create icons from SVG:
// Create a simple icon component from SVG
const svgNode = figma.createNodeFromSvg(
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">' +
'<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>'
);
// Wrap in a component
const iconComp = figma.createComponent();
iconComp.name = 'Icon/ChevronRight';
iconComp.resize(24, 24);
iconComp.clipsContent = true;
// Move SVG children into the component
for (const child of [...svgNode.children]) {
iconComp.appendChild(child);
}
svgNode.remove();
// Bind the icon fill to a color variable (so it respects themes)
// Find vector children and bind their fills
iconComp.findAll(n => n.type === 'VECTOR').forEach(vec => {
// For stroke-based icons:
if (vec.strokes.length > 0) {
const strokePaint = figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', iconColorVar
);
vec.strokes = [strokePaint];
}
});
iconComp.setSharedPluginData('dsb', 'run_id', RUN_ID);
iconComp.setSharedPluginData('dsb', 'key', 'icon/chevron-right');
return { iconCompId: iconComp.id };
Then use the returned iconCompId as the default value for INSTANCE_SWAP:
const iconKey = cs.addComponentProperty('Icon', 'INSTANCE_SWAP', ICON_COMP_ID);
Constraining swap options with preferredValues:
After adding the INSTANCE_SWAP property, you can optionally limit which components appear in the swap picker:
// Get the property definitions to find the exact key
const props = cs.componentPropertyDefinitions;
const iconPropKey = Object.keys(props).find(k => k.startsWith('Icon'));
// Set preferred values (array of component keys or instance IDs)
cs.editComponentProperty(iconPropKey, {
preferredValues: [
{ type: 'COMPONENT', key: chevronRightComp.key },
{ type: 'COMPONENT', key: chevronLeftComp.key },
{ type: 'COMPONENT', key: closeComp.key },
],
});
Icon library tip: Create all icon components on a dedicated Icons page before building any UI components. Then reference their IDs when wiring INSTANCE_SWAP properties.
componentPropertyReferences mapping
The componentPropertyReferences object maps a node's own property to a component property key:
| Node property | Component property type | Used for |
|---|---|---|
characters |
TEXT | Editable text content |
visible |
BOOLEAN | Show/hide toggle |
mainComponent |
INSTANCE_SWAP | Swap nested instances |
7. sharedPluginData Tagging for Idempotency
Tag EVERY created node immediately after creation. This enables safe cleanup, resumability, and idempotency checks.
// After creating any node:
node.setSharedPluginData('dsb', 'run_id', RUN_ID); // identifies the build run
node.setSharedPluginData('dsb', 'phase', 'phase3'); // which phase created it
node.setSharedPluginData('dsb', 'key', KEY); // unique logical key for this entity
// Reading back:
const runId = node.getSharedPluginData('dsb', 'run_id'); // '' if not set
const key = node.getSharedPluginData('dsb', 'key');
Key naming convention: use /-separated logical paths that mirror the entity hierarchy:
'component/button/base'
'component/button/variant/Medium/Primary/Default'
'componentset/button'
'doc/button'
'page/button'
Idempotency check before creating: before creating a node, scan the current page for an existing node with the same key:
const existing = figma.currentPage.findAll(n =>
n.getSharedPluginData('dsb', 'key') === 'componentset/button'
);
if (existing.length > 0) {
// Skip creation — already done. Return existing node's ID.
return { componentSetId: existing[0].id };
}
8. Documentation
Page title + description frame
The documentation frame (see Section 2) should contain:
- Component name as a large title (32px+ Bold)
- 1–3 sentence description of what the component is and when to use it
- Spec notes (sizes, spacing values, accessibility notes)
Component description property
Set the description on the ComponentSet — it appears in the Figma properties panel and is exported as documentation:
cs.description = 'Buttons allow users to take actions and make choices. Use Primary for the highest-emphasis action on a page.';
documentationLinks
Link to external documentation (Storybook, design spec, tokens reference):
cs.documentationLinks = [
{ uri: 'https://your-storybook.com/button' }
];
Node names and organization
- ComponentSet: plain component name —
'Button' - Individual variants:
'Property=Value, Property=Value'format (match the file's existing casing) - Child nodes: semantic names —
'label','icon','container','state-layer' - Documentation frames:
'ComponentName / Documentation'
9. Validation
Always validate after creating or modifying a component before proceeding to the next one.
get_metadata structural checks
After creating the component set, call get_metadata on the ComponentSet node and verify:
variantGroupPropertieslists the expected axes with the correct value arrayscomponentPropertyDefinitionscontains the expected TEXT/BOOLEAN/INSTANCE_SWAP propertieschildren.lengthequals the expected variant count (e.g., 18 for 3×2×3)- No children are named
'Component 1'(unnamed components are a sign of a bug)
get_screenshot — Visual Validation (Critical)
get_screenshot returns an image of the specified node. Call it on the component page node (not the component set) to see the full page including documentation and grid labels.
Tool: get_screenshot
Args: { nodeId: "PAGE_NODE_ID", fileKey: "FILE_KEY" }
How to use the screenshot:
- Display it to the user — this is the primary purpose. Show the screenshot as part of the user checkpoint: "Here's the Button component. Does it look right?"
- Analyze it yourself — if you have vision capabilities, check the visual checklist below. If you don't (text-only agent), fall back to structural validation only via
get_metadataand describe what you created textually.
Visual validation checklist (check each item when viewing the screenshot):
| # | Check | What "good" looks like | What "broken" looks like |
|---|---|---|---|
| 1 | Grid layout | Variants in neat rows and columns with consistent spacing | All variants piled at top-left (0,0 stacking bug) |
| 2 | Color fills | Components show distinct, correct colors per style variant | All components are black or same color (variable binding failed) |
| 3 | Size differentiation | Small variants are visibly smaller than Large variants | All variants are the same size (height/padding not bound to variables) |
| 4 | Text readability | Labels are visible with correct font and color | Text is invisible (white on white), missing, or shows "undefined" |
| 5 | Spacing/padding | Interior padding visible, components aren't "shrink-wrapped" | Components look cramped or have no visible internal space |
| 6 | State differentiation | Hover/Pressed variants have visible color differences from Default | All states look identical (state-specific fills not applied) |
| 7 | Disabled state | Lower opacity or muted colors compared to active states | Disabled looks identical to Default |
| 8 | Documentation frame | Title + description text visible above or beside the component grid | No documentation, or it overlaps the component set |
| 9 | Grid labels | Row/column headers visible around the component set (if added) | Labels overlap the grid or are missing |
| 10 | Component set boundary | Gray background frame wraps all variants with even padding | Frame is too small (variants clipped) or way too large |
Screenshot → diagnosis → fix mapping:
| Screenshot shows | Diagnosis | Fix script |
|---|---|---|
| All variants stacked top-left | Grid layout wasn't applied after combineAsVariants |
Re-run the grid layout script (§5) |
| Everything black/same color | Variable bindings failed or variables don't have values for the active mode | Re-run variable binding, check mode values |
| No text visible | Font wasn't loaded, or text fill is same color as background | Check loadFontAsync was called; bind text fill to color/text/* variable |
| Variants all same size | Padding/height not bound to size variables | Re-run bindVariablesToComponent with size-specific tokens |
| Component set frame tiny | resizeWithoutConstraints wasn't called or used wrong dimensions |
Re-calculate bounds from children and resize |
| Doc frame overlaps components | Component set positioned at same x,y as doc frame | Move component set: cs.x = docFrame.x + docFrame.width + 60 |
When visual analysis isn't available: If your model can't process images (text-only mode), validate structurally instead:
- Call
get_metadataon the component set — verify child count, property definitions, variant names - Run an
use_figmathat samples key properties:
const cs = await figma.getNodeByIdAsync(CS_ID);
const sample = cs.children.slice(0, 3).map(c => ({
name: c.name,
width: c.width, height: c.height,
x: c.x, y: c.y,
fills: c.fills?.map(f => f.type === 'SOLID' ?
{ r: f.color.r.toFixed(2), g: f.color.g.toFixed(2), b: f.color.b.toFixed(2), boundVar: f.boundVariables?.color?.id } : f.type
),
}));
return { sampleVariants: sample, totalChildren: cs.children.length };
This gives you positions (grid working?), dimensions (size differentiation?), and fill info (bindings working?) without needing vision.
When to take a screenshot:
- After EVERY completed component (mandatory — part of the user checkpoint)
- After creating the foundations documentation page
- After final QA (screenshot every page)
- Do NOT screenshot after every intermediate step (wastes tool calls)
Common issues
| Symptom | Likely cause | Fix |
|---|---|---|
| All variants stacked at (0,0) | combineAsVariants was called but children were never repositioned |
Re-run grid layout script |
| Variants show wrong colors | Variable bindings applied after combineAsVariants instead of before |
Rebind on component set children |
| Variant count wrong | Clone loop indexing error | Print components.map(c => c.name) before combining |
| BOOLEAN property has no effect | componentPropertyReferences was set on the component set frame, not on the child node |
Find the actual child node and set references there |
| INSTANCE_SWAP shows no swap option | Default value was not a valid component ID | Pass a real existing component ID as defaultValue |
combineAsVariants throws |
At least one node in the array is not a ComponentNode |
Filter array: nodes.filter(n => n.type === 'COMPONENT') |
addComponentProperty returns unexpected key |
Expected — the key gets a #id:id suffix |
Save the returned value immediately: const key = cs.addComponentProperty(...) |
10. Complete Worked Example: Button Component
This shows the full sequence of use_figma calls for a Button component, including state passing between calls. Replace RUN_ID and variable IDs with your actual values from the state ledger.
Call 1: Create the component page
Goal: Create (or find) the Button page.
State input: None
State output: { pageId }
let page = figma.root.children.find(p => p.name === 'Button');
if (!page) { page = figma.createPage(); page.name = 'Button'; }
page.setSharedPluginData('dsb', 'run_id', 'ds-build-2024-001');
page.setSharedPluginData('dsb', 'key', 'page/button');
return { pageId: page.id };
Call 2: Create documentation frame
Goal: Add title + description frame.
State input: { pageId }
State output: { docFrameId }
const PAGE_ID = 'PAGE_ID_FROM_STATE';
const page = await figma.getNodeByIdAsync(PAGE_ID);
await figma.setCurrentPageAsync(page);
// Idempotency check
const existing = page.findAll(n => n.getSharedPluginData('dsb', 'key') === 'doc/button');
if (existing.length > 0) {
return { docFrameId: existing[0].id };
}
await figma.loadFontAsync({ family: 'Inter', style: 'Bold' });
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
const docFrame = figma.createFrame();
docFrame.name = 'Button / Documentation';
docFrame.x = 40; docFrame.y = 40;
docFrame.layoutMode = 'VERTICAL';
docFrame.primaryAxisSizingMode = 'AUTO';
docFrame.counterAxisSizingMode = 'FIXED';
docFrame.resize(560, 100);
docFrame.paddingTop = 40; docFrame.paddingBottom = 40;
docFrame.paddingLeft = 40; docFrame.paddingRight = 40;
docFrame.itemSpacing = 16;
docFrame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
const title = figma.createText();
title.fontName = { family: 'Inter', style: 'Bold' };
title.fontSize = 32;
title.characters = 'Button';
docFrame.appendChild(title);
const desc = figma.createText();
desc.fontName = { family: 'Inter', style: 'Regular' };
desc.fontSize = 14;
desc.characters = 'Buttons allow users to take actions with a single tap. Use Primary for the highest-emphasis action on a page, Secondary for supporting actions.';
desc.layoutSizingHorizontal = 'FILL';
docFrame.appendChild(desc);
docFrame.setSharedPluginData('dsb', 'run_id', 'ds-build-2024-001');
docFrame.setSharedPluginData('dsb', 'key', 'doc/button');
return { docFrameId: docFrame.id };
Call 3: Create base component
Goal: Create the base component with auto-layout and all variable bindings.
State input: { pageId } + variable IDs from Phase 1
State output: { baseCompId }
(See Section 3 for full code — substituting the actual variable IDs from the state ledger.)
Call 4: Create all variants
Goal: Clone base and produce all 18 variants (3 Size × 2 Style × 3 State).
State input: { pageId, baseCompId } + variable IDs
State output: { variantIds: ['id1', 'id2', ..., 'id18'] }
const RUN_ID = 'ds-build-2024-001';
const BASE_ID = 'BASE_COMP_ID_FROM_STATE';
const PAGE_ID = 'PAGE_ID_FROM_STATE';
// Variable IDs from state ledger:
const VAR = {
bg_primary: 'VAR_ID_1',
text_primary: 'VAR_ID_2',
bg_secondary: 'VAR_ID_3',
text_secondary: 'VAR_ID_4',
bg_disabled: 'VAR_ID_5',
text_disabled: 'VAR_ID_6',
padding_sm: 'VAR_ID_7',
padding_md: 'VAR_ID_8',
padding_lg: 'VAR_ID_9',
};
const page = await figma.getNodeByIdAsync(PAGE_ID);
await figma.setCurrentPageAsync(page);
const base = await figma.getNodeByIdAsync(BASE_ID);
// Load all variables
const vars = {};
for (const [k, v] of Object.entries(VAR)) {
vars[k] = await figma.variables.getVariableByIdAsync(v);
}
const axes = {
Size: ['Small', 'Medium', 'Large'],
Style: ['Primary', 'Secondary'],
State: ['Default', 'Hover', 'Disabled'],
};
const paddingMap = { Small: vars.padding_sm, Medium: vars.padding_md, Large: vars.padding_lg };
const components = [];
for (const size of axes.Size) {
for (const style of axes.Style) {
for (const state of axes.State) {
const clone = base.clone();
clone.name = `Size=${size}, Style=${style}, State=${state}`;
clone.setBoundVariable('paddingTop', paddingMap[size]);
clone.setBoundVariable('paddingBottom', paddingMap[size]);
clone.setBoundVariable('paddingLeft', paddingMap[size]);
clone.setBoundVariable('paddingRight', paddingMap[size]);
const isDisabled = state === 'Disabled';
const bgV = isDisabled ? vars.bg_disabled : (style === 'Primary' ? vars.bg_primary : vars.bg_secondary);
const txV = isDisabled ? vars.text_disabled : (style === 'Primary' ? vars.text_primary : vars.text_secondary);
clone.fills = [figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', bgV
)];
const labelNode = clone.findOne(n => n.name === 'label');
labelNode.fills = [figma.variables.setBoundVariableForPaint(
{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }, 'color', txV
)];
clone.setSharedPluginData('dsb', 'run_id', RUN_ID);
clone.setSharedPluginData('dsb', 'key', `component/button/variant/${size}/${style}/${state}`);
components.push(clone);
}
}
}
return { variantIds: components.map(c => c.id) };
Call 5: combineAsVariants + grid layout
Goal: Combine all 18 variants into a ComponentSet and lay them out in a grid.
State input: { pageId, variantIds } (18 IDs)
State output: { componentSetId }
(See Section 5 for full code.)
Call 6: Add component properties
Goal: Add TEXT, BOOLEAN, INSTANCE_SWAP properties and wire them to child nodes.
State input: { pageId, componentSetId }
State output: { componentSetId, properties: { labelKey, showIconKey, iconKey } }
const CS_ID = 'CS_ID_FROM_STATE';
const DEFAULT_ICON_ID = 'ICON_COMP_ID_FROM_STATE';
const page = figma.root.children.find(p => p.name === 'Button');
await figma.setCurrentPageAsync(page);
const cs = await figma.getNodeByIdAsync(CS_ID);
cs.description = 'Buttons allow users to take actions and make choices with a single tap.';
cs.documentationLinks = [{ uri: 'https://your-storybook.com/button' }];
// Add properties — save returned keys
const labelKey = cs.addComponentProperty('Label', 'TEXT', 'Button');
const showIconKey = cs.addComponentProperty('Show Icon', 'BOOLEAN', true);
const iconKey = cs.addComponentProperty('Icon', 'INSTANCE_SWAP', DEFAULT_ICON_ID);
// Wire to children
for (const child of cs.children) {
const labelNode = child.findOne(n => n.name === 'label');
if (labelNode) labelNode.componentPropertyReferences = { characters: labelKey };
const iconNode = child.findOne(n => n.name === 'icon');
if (iconNode) {
iconNode.componentPropertyReferences = {
visible: showIconKey,
...(iconNode.type === 'INSTANCE' ? { mainComponent: iconKey } : {}),
};
}
}
return {
componentSetId: cs.id,
properties: { labelKey, showIconKey, iconKey },
};
Call 7: Validate with get_metadata
Goal: Structural check — variant count, properties, axes.
Action: Call get_metadata on the ComponentSet node ID (from state). Verify in the result:
children.length === 18variantGroupPropertieshasSize,Style,Statekeys with correct value arrayscomponentPropertyDefinitionshasLabel,Show Icon,Iconentries
Call 8: Validate with get_screenshot
Goal: Visual check — layout, colors, text.
Action: Call get_screenshot on the Button page. Inspect the screenshot. If variants are stacked, re-run Call 5. If colors look wrong, inspect variable bindings.
Checkpoint
After Call 8: show the screenshot to the user. Ask: "Here's the Button component with 18 variants. Does this look correct?" Do not proceed to the next component until the user approves.