
Figma Plugin
- 1 installs
- 137 repo stars
- Updated May 25, 2026
- bergside/figma-design-skills-plugin
Figma Plugin Development is a skill that guides building Figma plugins with the Plugin API, including the main-sandbox / iframe-UI postMessage architecture.
About
Figma Plugin Development is a skill that teaches an agent to build Figma plugins using the Plugin API. It explains the two-thread architecture where the main sandbox handles figma.* node operations and the iframe UI thread handles the interface, communicating via postMessage. A developer uses it when creating design-automation tools or extending Figma, with ten reference files on nodes, rendering, UI, and publishing.
- Guides building Figma plugins with the Plugin API
- Explains the two-thread main-sandbox / iframe-UI postMessage architecture
- 10 bundled reference files covering nodes, rendering, UI, and publishing
Figma Plugin by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,912 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
figma-plugin capabilities & compatibility
- Capabilities
- figma plugin · design automation · ui development
- Works with
- figma
- Use cases
- frontend · ui design
- Pricing
- Free
What figma-plugin says it does
Build plugins that extend Figma's functionality using the Plugin API.
Communication between threads via `figma.ui.postMessage()` and `onmessage`
Plugins must be performant — avoid blocking the main thread
npx skills add https://github.com/bergside/figma-design-skills-plugin --skill figma-pluginAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 137 |
| Last updated | May 25, 2026 |
| Repository | bergside/figma-design-skills-plugin ↗ |
What it does
Build a Figma plugin that manipulates nodes and styles via the Plugin API with a postMessage sandbox/UI split.
Who is it for?
Building Figma plugins and design-automation tools with correct sandbox/UI messaging.
Skip if: Non-Figma design work or plugins for other design tools.
When should I use this skill?
You are building a Figma plugin or automating Figma via the Plugin API.
By the numbers
- 6-step Quick Start Checklist
- 10 bundled reference files
Files
Figma Plugin Development
Build plugins that extend Figma's functionality using the Plugin API.
Architecture
Figma plugins run in two threads communicating via postMessage:
- Main thread (sandbox): Plugin API access, node manipulation,
figma.*calls - UI thread (iframe): HTML/CSS/JS interface, no Figma API access, npm packages allowed
Key Principles
- Main thread handles all Figma document operations
- UI thread handles user interface and external APIs
- Communication between threads via
figma.ui.postMessage()andonmessage - Plugins must be performant — avoid blocking the main thread
Quick Start Checklist
1. Set up project with manifest.json (name, id, main, ui) 2. Create main thread code (code.ts) with plugin logic 3. Create UI (ui.html) with interface elements 4. Wire up postMessage communication between threads 5. Test in Figma development mode 6. Publish via Figma Community
References
| Reference | Description |
|---|---|
| project-structure-and-build.md | Manifest, TypeScript setup, build configuration |
| development-testing-and-publishing.md | Dev workflow, testing, publishing, troubleshooting |
| api-globals-and-nodes.md | Global objects, node types, components |
| api-rendering-and-advanced.md | Paints, effects, auto layout, styles, variables, events |
| ui-architecture-and-messaging.md | iframe UI, postMessage, typed messages, plain HTML |
| ui-react-and-theming.md | React setup, hooks, Figma theme colors |
| ui-patterns-and-resources.md | Loading states, tabs, color pickers, file downloads |
| selection-traversal-and-batching.md | Selection handling, node traversal, batch operations |
| colors-and-text.md | Color conversion, manipulation, text operations |
| layout-storage-and-utilities.md | Positioning, alignment, storage, error handling, utilities |
Plugin API: Globals and Node Types
Core Figma Plugin API reference for global objects and node types.
Global Objects
figma
The main API entry point, available in the main thread.
// Document
figma.root // DocumentNode
figma.currentPage // PageNode
figma.currentPage.selection // readonly SceneNode[]
// Create nodes
figma.createRectangle()
figma.createEllipse()
figma.createPolygon()
figma.createStar()
figma.createLine()
figma.createFrame()
figma.createComponent()
figma.createComponentSet()
figma.createText()
figma.createBooleanOperation()
figma.createVector()
figma.createSlice()
figma.createConnector() // FigJam
figma.createSticky() // FigJam
figma.createShapeWithText() // FigJam
// UI
figma.showUI(__html__, options?)
figma.ui.postMessage(message)
figma.ui.onmessage = (msg) => {}
figma.ui.resize(width, height)
figma.ui.close()
figma.closePlugin(message?)
// Viewport
figma.viewport.center // Vector
figma.viewport.zoom // number
figma.viewport.scrollAndZoomIntoView(nodes)
// Styles
figma.getLocalPaintStyles()
figma.getLocalTextStyles()
figma.getLocalEffectStyles()
figma.getLocalGridStyles()
figma.createPaintStyle()
figma.createTextStyle()
figma.createEffectStyle()
figma.createGridStyle()
// Search
figma.getNodeById(id)
figma.getStyleById(id)
figma.currentPage.findAll(callback?)
figma.currentPage.findOne(callback)
figma.currentPage.findChildren(callback?)
figma.currentPage.findAllWithCriteria({ types: [...] })
// Events
figma.on('selectionchange', callback)
figma.on('currentpagechange', callback)
figma.on('close', callback)
figma.on('run', callback)
figma.on('drop', callback)
figma.once(event, callback)
figma.off(event, callback)
// Notifications
figma.notify(message, options?)
// Storage
figma.clientStorage.getAsync(key)
figma.clientStorage.setAsync(key, value)
figma.clientStorage.deleteAsync(key)
figma.clientStorage.keysAsync()
// Fonts
figma.loadFontAsync(fontName)
figma.listAvailableFontsAsync()
// Images
figma.createImage(data) // Uint8Array
figma.getImageByHash(hash)
// Variables (Design Tokens)
figma.variables.getLocalVariables()
figma.variables.getLocalVariableCollections()
figma.variables.createVariable(name, collectionId, type)
figma.variables.createVariableCollection(name)
// Parameters (for parameterized plugins)
figma.parameters.on('input', callback)
// Payments
figma.payments.getPluginPaymentTokenAsync()
figma.payments.initiateCheckoutAsync(options)---
Node Types
Document Structure
// DocumentNode (figma.root)
interface DocumentNode {
readonly type: 'DOCUMENT';
readonly children: readonly PageNode[];
name: string;
}
// PageNode
interface PageNode {
readonly type: 'PAGE';
readonly children: readonly SceneNode[];
name: string;
selection: readonly SceneNode[];
selectedTextRange: { node: TextNode; start: number; end: number } | null;
backgrounds: readonly Paint[];
guides: readonly Guide[];
// Methods
findAll(callback?: (node: SceneNode) => boolean): SceneNode[];
findOne(callback: (node: SceneNode) => boolean): SceneNode | null;
findChildren(callback?: (node: SceneNode) => boolean): SceneNode[];
findAllWithCriteria(criteria: { types: NodeType[] }): SceneNode[];
}Frame & Group
interface FrameNode {
readonly type: 'FRAME';
// Children
readonly children: readonly SceneNode[];
appendChild(child: SceneNode): void;
insertChild(index: number, child: SceneNode): void;
// Layout
x: number;
y: number;
width: number;
height: number;
resize(width: number, height: number): void;
resizeWithoutConstraints(width: number, height: number): void;
// Auto Layout
layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
primaryAxisSizingMode: 'FIXED' | 'AUTO';
counterAxisSizingMode: 'FIXED' | 'AUTO';
primaryAxisAlignItems: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN';
counterAxisAlignItems: 'MIN' | 'CENTER' | 'MAX' | 'BASELINE';
paddingLeft: number;
paddingRight: number;
paddingTop: number;
paddingBottom: number;
itemSpacing: number;
// Appearance
fills: readonly Paint[];
strokes: readonly Paint[];
strokeWeight: number;
cornerRadius: number;
opacity: number;
effects: readonly Effect[];
// Constraints
constraints: Constraints;
// Clipping
clipsContent: boolean;
}
interface GroupNode {
readonly type: 'GROUP';
readonly children: readonly SceneNode[];
// Groups cannot have fills/strokes directly
// Transform only
}Shapes
interface RectangleNode {
readonly type: 'RECTANGLE';
x: number;
y: number;
width: number;
height: number;
// Corner radius
cornerRadius: number;
topLeftRadius: number;
topRightRadius: number;
bottomLeftRadius: number;
bottomRightRadius: number;
// Appearance
fills: readonly Paint[];
strokes: readonly Paint[];
strokeWeight: number;
strokeAlign: 'INSIDE' | 'OUTSIDE' | 'CENTER';
opacity: number;
effects: readonly Effect[];
}
interface EllipseNode {
readonly type: 'ELLIPSE';
x: number;
y: number;
width: number;
height: number;
// Arc
arcData: ArcData;
// Appearance
fills: readonly Paint[];
strokes: readonly Paint[];
}
interface PolygonNode {
readonly type: 'POLYGON';
pointCount: number; // Number of sides
// ... same appearance properties
}
interface StarNode {
readonly type: 'STAR';
pointCount: number;
innerRadius: number; // 0-1, ratio of inner to outer radius
// ... same appearance properties
}
interface LineNode {
readonly type: 'LINE';
x: number;
y: number;
width: number; // Length of line
rotation: number;
strokes: readonly Paint[];
strokeWeight: number;
strokeCap: 'NONE' | 'ROUND' | 'SQUARE' | 'ARROW_LINES' | 'ARROW_EQUILATERAL';
}
interface VectorNode {
readonly type: 'VECTOR';
vectorNetwork: VectorNetwork;
vectorPaths: VectorPaths;
// For complex paths
}Text
interface TextNode {
readonly type: 'TEXT';
// Content
characters: string;
// Must load font before setting characters
fontName: FontName | typeof figma.mixed;
fontSize: number | typeof figma.mixed;
fontWeight: number | typeof figma.mixed;
// Styling
textAlignHorizontal: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED';
textAlignVertical: 'TOP' | 'CENTER' | 'BOTTOM';
textAutoResize: 'NONE' | 'WIDTH_AND_HEIGHT' | 'HEIGHT' | 'TRUNCATE';
textCase: TextCase | typeof figma.mixed;
textDecoration: TextDecoration | typeof figma.mixed;
letterSpacing: LetterSpacing | typeof figma.mixed;
lineHeight: LineHeight | typeof figma.mixed;
paragraphIndent: number;
paragraphSpacing: number;
// Range methods (for mixed styles)
getRangeFontName(start: number, end: number): FontName | typeof figma.mixed;
setRangeFontName(start: number, end: number, value: FontName): void;
getRangeFontSize(start: number, end: number): number | typeof figma.mixed;
setRangeFontSize(start: number, end: number, value: number): void;
getRangeFills(start: number, end: number): Paint[] | typeof figma.mixed;
setRangeFills(start: number, end: number, value: Paint[]): void;
// ... more range methods for other properties
// Hyperlinks
getRangeHyperlink(start: number, end: number): HyperlinkTarget | null;
setRangeHyperlink(start: number, end: number, value: HyperlinkTarget | null): void;
}
interface FontName {
family: string;
style: string; // 'Regular', 'Bold', 'Italic', etc.
}
// Load font before use
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
await figma.loadFontAsync({ family: 'Inter', style: 'Bold' });Components
interface ComponentNode {
readonly type: 'COMPONENT';
// Same as FrameNode, plus:
readonly key: string; // Unique identifier
description: string;
documentationLinks: readonly DocumentationLink[];
// Create instance
createInstance(): InstanceNode;
}
interface ComponentSetNode {
readonly type: 'COMPONENT_SET';
readonly children: readonly ComponentNode[]; // Variants
// Component set for variants
}
interface InstanceNode {
readonly type: 'INSTANCE';
// Reference to main component
readonly mainComponent: ComponentNode | null;
// Override properties
overrides: readonly Override[];
// Swap instance
swapComponent(newComponent: ComponentNode): void;
// Detach from component
detachInstance(): FrameNode;
// Reset overrides
resetOverrides(): void;
}Plugin API: Rendering and Advanced Features
Paint types, effects, auto layout, styles, variables, events, export, and helpers.
Paint Types
type Paint = SolidPaint | GradientPaint | ImagePaint | VideoPaint;
interface SolidPaint {
type: 'SOLID';
color: RGB;
opacity?: number; // 0-1
visible?: boolean;
blendMode?: BlendMode;
}
interface GradientPaint {
type: 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND';
gradientStops: readonly ColorStop[];
gradientTransform: Transform;
opacity?: number;
visible?: boolean;
}
interface ColorStop {
position: number; // 0-1
color: RGBA;
}
interface ImagePaint {
type: 'IMAGE';
imageHash: string | null;
scaleMode: 'FILL' | 'FIT' | 'CROP' | 'TILE';
imageTransform?: Transform;
scalingFactor?: number;
rotation?: number;
filters?: ImageFilters;
opacity?: number;
visible?: boolean;
}
// Create image paint
const imageData: Uint8Array = /* load image bytes */;
const image = figma.createImage(imageData);
node.fills = [{
type: 'IMAGE',
imageHash: image.hash,
scaleMode: 'FILL',
}];---
Effects
type Effect = DropShadowEffect | InnerShadowEffect | BlurEffect | BackgroundBlurEffect;
interface DropShadowEffect {
type: 'DROP_SHADOW';
color: RGBA;
offset: Vector;
radius: number;
spread?: number;
visible: boolean;
blendMode: BlendMode;
showShadowBehindNode?: boolean;
}
interface InnerShadowEffect {
type: 'INNER_SHADOW';
color: RGBA;
offset: Vector;
radius: number;
spread?: number;
visible: boolean;
blendMode: BlendMode;
}
interface BlurEffect {
type: 'LAYER_BLUR';
radius: number;
visible: boolean;
}
interface BackgroundBlurEffect {
type: 'BACKGROUND_BLUR';
radius: number;
visible: boolean;
}
// Example
node.effects = [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true,
blendMode: 'NORMAL',
}
];---
Auto Layout
// Enable auto layout
frame.layoutMode = 'VERTICAL'; // or 'HORIZONTAL'
// Direction and alignment
frame.primaryAxisAlignItems = 'CENTER'; // Main axis: MIN, CENTER, MAX, SPACE_BETWEEN
frame.counterAxisAlignItems = 'CENTER'; // Cross axis: MIN, CENTER, MAX, BASELINE
// Sizing
frame.primaryAxisSizingMode = 'AUTO'; // FIXED or AUTO (hug)
frame.counterAxisSizingMode = 'AUTO'; // FIXED or AUTO (hug)
// Padding
frame.paddingTop = 16;
frame.paddingBottom = 16;
frame.paddingLeft = 16;
frame.paddingRight = 16;
// Gap between items
frame.itemSpacing = 8;
// Wrap (if supported)
frame.layoutWrap = 'WRAP'; // or 'NO_WRAP'
// Child properties (when parent has auto layout)
child.layoutPositioning = 'AUTO'; // or 'ABSOLUTE'
child.layoutAlign = 'STRETCH'; // INHERIT, STRETCH, MIN, CENTER, MAX
child.layoutGrow = 1; // Flex grow
// Fill container
child.layoutSizingHorizontal = 'FILL'; // FIXED, HUG, or FILL
child.layoutSizingVertical = 'HUG';---
Styles
// Get existing styles
const paintStyles = figma.getLocalPaintStyles();
const textStyles = figma.getLocalTextStyles();
const effectStyles = figma.getLocalEffectStyles();
// Create paint style
const style = figma.createPaintStyle();
style.name = 'Brand/Primary';
style.paints = [{ type: 'SOLID', color: { r: 0, g: 0.5, b: 1 } }];
// Apply style to node
node.fillStyleId = style.id;
// Create text style
const textStyle = figma.createTextStyle();
textStyle.name = 'Heading/H1';
textStyle.fontName = { family: 'Inter', style: 'Bold' };
textStyle.fontSize = 32;
textStyle.lineHeight = { value: 40, unit: 'PIXELS' };
// Apply text style
textNode.textStyleId = textStyle.id;
// Create effect style
const effectStyle = figma.createEffectStyle();
effectStyle.name = 'Shadow/Medium';
effectStyle.effects = [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.15 },
offset: { x: 0, y: 4 },
radius: 12,
visible: true,
blendMode: 'NORMAL',
}
];
// Apply effect style
node.effectStyleId = effectStyle.id;---
Variables (Design Tokens)
// Get variables
const variables = figma.variables.getLocalVariables();
const collections = figma.variables.getLocalVariableCollections();
// Create collection
const collection = figma.variables.createVariableCollection('Colors');
// Add mode (for themes)
const darkModeId = collection.addMode('Dark');
const lightModeId = collection.defaultModeId; // Already exists
// Create variable
const primaryColor = figma.variables.createVariable(
'color/primary',
collection.id,
'COLOR'
);
// Set values per mode
primaryColor.setValueForMode(lightModeId, { r: 0, g: 0.5, b: 1 });
primaryColor.setValueForMode(darkModeId, { r: 0.3, g: 0.7, b: 1 });
// Bind variable to node
node.setBoundVariable('fills', primaryColor.id);
// Variable types
type VariableResolvedDataType =
| 'BOOLEAN'
| 'FLOAT'
| 'STRING'
| 'COLOR';---
Events
// Selection changed
figma.on('selectionchange', () => {
console.log('Selection:', figma.currentPage.selection);
});
// Page changed
figma.on('currentpagechange', () => {
console.log('Current page:', figma.currentPage.name);
});
// Document changed (for tracking specific changes)
figma.on('documentchange', (event) => {
for (const change of event.documentChanges) {
console.log(change.type, change.id);
}
});
// Plugin close
figma.on('close', () => {
// Cleanup
});
// Drop event (drag and drop onto canvas)
figma.on('drop', (event) => {
const { items, dropMetadata } = event;
// items: dropped files/data
// dropMetadata: position info
return false; // Return false to let Figma handle it, true to cancel
});
// Timer events (use setTimeout/setInterval carefully)
// Available but can block UI - use sparingly
// Remove listener
const handler = () => {};
figma.on('selectionchange', handler);
figma.off('selectionchange', handler);
// Once (auto-removes after first call)
figma.once('selectionchange', () => {
console.log('First selection change only');
});---
Export
// Export settings
interface ExportSettings {
format: 'PNG' | 'JPG' | 'SVG' | 'PDF';
suffix?: string;
contentsOnly?: boolean;
constraint?: {
type: 'SCALE' | 'WIDTH' | 'HEIGHT';
value: number;
};
}
// Export node
const bytes = await node.exportAsync({
format: 'PNG',
constraint: { type: 'SCALE', value: 2 }, // 2x
});
// Export as SVG string
const svgString = await node.exportAsync({ format: 'SVG' });
const svg = String.fromCharCode(...svgString);
// Send to UI for download
figma.ui.postMessage({
type: 'export',
data: Array.from(bytes),
filename: `${node.name}.png`,
});---
Helpers
Figma Mixed
// When a property has different values across selection
if (textNode.fontSize === figma.mixed) {
// Multiple font sizes in this text node
console.log('Mixed font sizes');
} else {
console.log('Font size:', textNode.fontSize);
}Clone
// Clone a node
const clone = node.clone();
// Clone returns same type
const rectClone = rectangleNode.clone(); // RectangleNodeFind Nodes
// Find all text nodes in page
const textNodes = figma.currentPage.findAll(
(node) => node.type === 'TEXT'
) as TextNode[];
// Find first frame with name
const header = figma.currentPage.findOne(
(node) => node.type === 'FRAME' && node.name === 'Header'
) as FrameNode | null;
// Find by type (faster)
const allFrames = figma.currentPage.findAllWithCriteria({
types: ['FRAME']
});
// Find children (direct only)
const directTextChildren = parentFrame.findChildren(
(node) => node.type === 'TEXT'
);Absolute Position
// Get absolute position (relative to page)
const absoluteX = node.absoluteTransform[0][2];
const absoluteY = node.absoluteTransform[1][2];
// Or use absoluteBoundingBox
const bounds = node.absoluteBoundingBox;
if (bounds) {
console.log(bounds.x, bounds.y, bounds.width, bounds.height);
}Colors and Text
Utilities for color conversion, manipulation, and text operations.
Working with Colors
Color Conversion Utilities
// Hex to RGB (Figma format: 0-1)
function hexToRgb(hex: string): RGB {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return { r: 0, g: 0, b: 0 };
return {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
};
}
// RGB to Hex
function rgbToHex(color: RGB): string {
const r = Math.round(color.r * 255).toString(16).padStart(2, '0');
const g = Math.round(color.g * 255).toString(16).padStart(2, '0');
const b = Math.round(color.b * 255).toString(16).padStart(2, '0');
return `#${r}${g}${b}`.toUpperCase();
}
// HSL to RGB
function hslToRgb(h: number, s: number, l: number): RGB {
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return { r, g, b };
}
// Get solid fill color
function getSolidFillColor(node: GeometryMixin): RGB | null {
const fills = node.fills;
if (fills === figma.mixed || !Array.isArray(fills)) return null;
const solidFill = fills.find((f): f is SolidPaint => f.type === 'SOLID');
return solidFill?.color ?? null;
}
// Set solid fill
function setSolidFill(node: GeometryMixin, color: RGB, opacity?: number): void {
node.fills = [{
type: 'SOLID',
color,
opacity: opacity ?? 1,
}];
}Color Manipulation
// Lighten/darken color
function adjustBrightness(color: RGB, amount: number): RGB {
return {
r: Math.max(0, Math.min(1, color.r + amount)),
g: Math.max(0, Math.min(1, color.g + amount)),
b: Math.max(0, Math.min(1, color.b + amount)),
};
}
// Calculate contrast ratio (for accessibility)
function getContrastRatio(color1: RGB, color2: RGB): number {
const luminance = (c: RGB) => {
const [r, g, b] = [c.r, c.g, c.b].map(v => {
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const l1 = luminance(color1);
const l2 = luminance(color2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}---
Working with Text
Safe Text Operations
// Load font before modifying text
async function setTextContent(node: TextNode, text: string): Promise<void> {
// Load all fonts used in the text node
if (node.fontName !== figma.mixed) {
await figma.loadFontAsync(node.fontName);
} else {
// Mixed fonts - load all unique fonts
const fonts = new Set<string>();
const len = node.characters.length;
for (let i = 0; i < len; i++) {
const font = node.getRangeFontName(i, i + 1);
if (font !== figma.mixed) {
fonts.add(JSON.stringify(font));
}
}
await Promise.all(
[...fonts].map(f => figma.loadFontAsync(JSON.parse(f)))
);
}
node.characters = text;
}
// Create text node with font
async function createText(
text: string,
font: FontName = { family: 'Inter', style: 'Regular' },
fontSize: number = 14
): Promise<TextNode> {
const node = figma.createText();
await figma.loadFontAsync(font);
node.fontName = font;
node.fontSize = fontSize;
node.characters = text;
return node;
}Text Style Application
// Apply text style to range
async function styleTextRange(
node: TextNode,
start: number,
end: number,
style: {
fontName?: FontName;
fontSize?: number;
fills?: Paint[];
textDecoration?: 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH';
}
): Promise<void> {
if (style.fontName) {
await figma.loadFontAsync(style.fontName);
node.setRangeFontName(start, end, style.fontName);
}
if (style.fontSize) {
node.setRangeFontSize(start, end, style.fontSize);
}
if (style.fills) {
node.setRangeFills(start, end, style.fills);
}
if (style.textDecoration) {
node.setRangeTextDecoration(start, end, style.textDecoration);
}
}Development, Testing, and Publishing
Development workflow, testing strategies, publishing, and troubleshooting.
Development Workflow
Local Development
1. Open Figma Desktop 2. Plugins -> Development -> Import plugin from manifest 3. Select your `manifest.json` 4. Run `npm run watch` in terminal 5. Make changes -> Save -> Plugins -> Development -> Your Plugin 6. Use Console (Plugins -> Development -> Show/Hide Console)
Hot Reload (Sort of)
Figma doesn't support true hot reload. Workaround:
// code.ts - During development
figma.showUI(__html__, { width: 400, height: 300 });
// Close and reopen to see changes
// Keyboard shortcut: Cmd/Ctrl + Alt + P (run last plugin)Console Logging
// Main thread - appears in Figma console
console.log('Main thread log');
// UI thread - appears in browser console
// View with: Plugins -> Development -> Show/Hide Console
console.log('UI log');---
Testing
Manual Testing Checklist
- [ ] Plugin loads without errors
- [ ] UI displays correctly
- [ ] Selection handling works
- [ ] Empty selection handled
- [ ] Large selection handled
- [ ] Error states handled
- [ ] Cancel/close works
- [ ] Undo works after plugin actions
- [ ] Works in both light and dark themes
Automated Testing
// __tests__/utils.test.ts
import { hexToRgb, rgbToHex } from '../src/utils/colors';
describe('hexToRgb', () => {
test('converts hex to RGB', () => {
expect(hexToRgb('#FF0000')).toEqual({ r: 1, g: 0, b: 0 });
expect(hexToRgb('#00FF00')).toEqual({ r: 0, g: 1, b: 0 });
expect(hexToRgb('#0000FF')).toEqual({ r: 0, g: 0, b: 1 });
});
});// package.json
{
"scripts": {
"test": "jest"
},
"devDependencies": {
"jest": "^29.0.0",
"@types/jest": "^29.0.0",
"ts-jest": "^29.0.0"
}
}Mock Figma API
// __mocks__/figma.ts
export const figma = {
currentPage: {
selection: [],
findAll: jest.fn(() => []),
findOne: jest.fn(() => null),
},
createRectangle: jest.fn(() => ({
type: 'RECTANGLE',
x: 0,
y: 0,
resize: jest.fn(),
})),
notify: jest.fn(),
closePlugin: jest.fn(),
ui: {
postMessage: jest.fn(),
onmessage: null,
},
};
// jest.setup.ts
(global as any).figma = figma;---
Publishing
Prepare for Publishing
1. Create cover image (1920x960) 2. Create icon (128x128) 3. Write description 4. Test thoroughly 5. Build production bundle
manifest.json for Publishing
{
"name": "My Awesome Plugin",
"id": "1234567890123456789",
"api": "1.0.0",
"main": "dist/code.js",
"ui": "dist/ui.html",
"editorType": ["figma"]
}Publishing Steps
1. Go to Figma -> Plugins -> Manage plugins 2. Find your development plugin 3. Click "Publish" 4. Fill in details:
- Name (up to 50 characters)
- Tagline (up to 100 characters)
- Description (markdown supported)
- Cover image
- Categories
- Tags
5. Submit for review
Review Guidelines
Figma reviews plugins for:
- Security: No malicious code
- Privacy: Clear data handling
- Quality: Works as described
- Guidelines: Follows community guidelines
Common rejection reasons:
- Plugin crashes or has major bugs
- Missing or misleading description
- Inappropriate content
- Privacy policy issues (if collecting data)
Updating Published Plugin
1. Update version in code if tracking 2. Build production bundle 3. Go to Figma -> Plugins -> Manage plugins 4. Click "Edit" on your plugin 5. Upload new files 6. Update description if needed 7. Submit update
---
Common Issues
"Plugin timed out"
// PROBLEM: Long-running operation
for (let i = 0; i < 10000; i++) {
figma.createRectangle();
}
// SOLUTION: Batch with yields
async function createMany(count: number) {
for (let i = 0; i < count; i += 100) {
for (let j = 0; j < Math.min(100, count - i); j++) {
figma.createRectangle();
}
await new Promise(r => setTimeout(r, 0));
}
}"Cannot read properties of null"
// PROBLEM: Not checking for null
const node = figma.currentPage.selection[0];
node.name = 'New name'; // Crashes if nothing selected
// SOLUTION: Check first
const selection = figma.currentPage.selection;
if (selection.length === 0) {
figma.notify('Select something first');
return;
}
const node = selection[0];"Font not loaded"
// PROBLEM: Modifying text without loading font
const text = figma.createText();
text.characters = 'Hello'; // Error!
// SOLUTION: Load font first
const text = figma.createText();
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
text.characters = 'Hello';UI Not Showing
// PROBLEM: Missing __html__
figma.showUI('<html>...</html>'); // Won't work
// SOLUTION: Use __html__ (replaced at build time)
figma.showUI(__html__);
// Or for inline HTML (development only)
figma.showUI(`<html><body>Hello</body></html>`, { width: 200, height: 100 });Network Requests Blocked
// manifest.json - Add network access
{
"networkAccess": {
"allowedDomains": ["api.example.com"],
"reasoning": "Fetch data from our API"
}
}---
Templates & Starters
Official Templates
# Create React App template
npx create-react-app my-plugin --template figma-plugin
# Figma's official starter
# Download from: https://www.figma.com/plugin-docs/setup/Community Templates
# TypeScript + esbuild
npx degit nicebook/figma-plugin-typescript-template my-plugin
# React + TypeScript
npx degit nicebook/figma-plugin-react-template my-plugin
# Svelte
npx degit nicebook/figma-plugin-svelte-template my-pluginMinimal Starter
mkdir my-plugin && cd my-plugin
npm init -y
npm install --save-dev @figma/plugin-typings typescript esbuildCreate files:
manifest.json(copy from above)src/code.tstsconfig.json(copy from above)esbuild.config.js(copy from above)
Layout, Storage, and Utilities
Positioning, alignment, persistent storage, error handling, and utility patterns.
Positioning & Layout
Center in Viewport
function centerInViewport(node: SceneNode): void {
const center = figma.viewport.center;
node.x = center.x - node.width / 2;
node.y = center.y - node.height / 2;
}
// Scroll to node
function scrollToNode(node: SceneNode): void {
figma.viewport.scrollAndZoomIntoView([node]);
}Align Nodes
type Alignment = 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom';
function alignNodes(nodes: SceneNode[], alignment: Alignment): void {
if (nodes.length < 2) return;
const bounds = nodes.map(n => ({
left: n.x,
right: n.x + n.width,
top: n.y,
bottom: n.y + n.height,
centerX: n.x + n.width / 2,
centerY: n.y + n.height / 2,
}));
switch (alignment) {
case 'left': {
const minX = Math.min(...bounds.map(b => b.left));
nodes.forEach(n => { n.x = minX; });
break;
}
case 'center': {
const avgX = bounds.reduce((sum, b) => sum + b.centerX, 0) / bounds.length;
nodes.forEach(n => { n.x = avgX - n.width / 2; });
break;
}
case 'right': {
const maxX = Math.max(...bounds.map(b => b.right));
nodes.forEach(n => { n.x = maxX - n.width; });
break;
}
case 'top': {
const minY = Math.min(...bounds.map(b => b.top));
nodes.forEach(n => { n.y = minY; });
break;
}
case 'middle': {
const avgY = bounds.reduce((sum, b) => sum + b.centerY, 0) / bounds.length;
nodes.forEach(n => { n.y = avgY - n.height / 2; });
break;
}
case 'bottom': {
const maxY = Math.max(...bounds.map(b => b.bottom));
nodes.forEach(n => { n.y = maxY - n.height; });
break;
}
}
}Distribute Evenly
function distributeHorizontally(nodes: SceneNode[]): void {
if (nodes.length < 3) return;
// Sort by x position
const sorted = [...nodes].sort((a, b) => a.x - b.x);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalWidth = sorted.reduce((sum, n) => sum + n.width, 0);
const totalSpace = (last.x + last.width) - first.x - totalWidth;
const gap = totalSpace / (sorted.length - 1);
let currentX = first.x + first.width + gap;
for (let i = 1; i < sorted.length - 1; i++) {
sorted[i].x = currentX;
currentX += sorted[i].width + gap;
}
}
function distributeVertically(nodes: SceneNode[]): void {
if (nodes.length < 3) return;
const sorted = [...nodes].sort((a, b) => a.y - b.y);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalHeight = sorted.reduce((sum, n) => sum + n.height, 0);
const totalSpace = (last.y + last.height) - first.y - totalHeight;
const gap = totalSpace / (sorted.length - 1);
let currentY = first.y + first.height + gap;
for (let i = 1; i < sorted.length - 1; i++) {
sorted[i].y = currentY;
currentY += sorted[i].height + gap;
}
}---
Storage Patterns
Persistent Settings
interface PluginSettings {
lastColor: string;
gridSize: number;
showGuides: boolean;
}
const DEFAULT_SETTINGS: PluginSettings = {
lastColor: '#000000',
gridSize: 8,
showGuides: true,
};
async function loadSettings(): Promise<PluginSettings> {
const stored = await figma.clientStorage.getAsync('settings');
return { ...DEFAULT_SETTINGS, ...stored };
}
async function saveSettings(settings: Partial<PluginSettings>): Promise<void> {
const current = await loadSettings();
await figma.clientStorage.setAsync('settings', { ...current, ...settings });
}
// Usage
const settings = await loadSettings();
settings.lastColor = '#FF0000';
await saveSettings(settings);Node Data
// Store data on a node (survives copy/paste)
function setNodeData<T>(node: SceneNode, key: string, data: T): void {
node.setPluginData(key, JSON.stringify(data));
}
function getNodeData<T>(node: SceneNode, key: string): T | null {
const data = node.getPluginData(key);
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
}
// Example: Track which nodes were processed
interface ProcessedMeta {
processedAt: string;
version: string;
}
function markAsProcessed(node: SceneNode): void {
setNodeData<ProcessedMeta>(node, 'processed', {
processedAt: new Date().toISOString(),
version: '1.0.0',
});
}
function isProcessed(node: SceneNode): boolean {
return getNodeData<ProcessedMeta>(node, 'processed') !== null;
}---
Error Handling
Safe Execution
async function safeExecute<T>(
fn: () => T | Promise<T>,
errorMessage: string = 'An error occurred'
): Promise<T | null> {
try {
return await fn();
} catch (error) {
console.error(error);
figma.notify(errorMessage, { error: true });
return null;
}
}
// Usage
const result = await safeExecute(
() => processNodes(selection),
'Failed to process nodes'
);
if (result === null) {
figma.closePlugin();
return;
}Validation
function validateInput(input: unknown): input is ValidInput {
if (!input || typeof input !== 'object') return false;
// Add validation logic
return true;
}
// With error messages
interface ValidationResult {
valid: boolean;
errors: string[];
}
function validateCreateInput(input: any): ValidationResult {
const errors: string[] = [];
if (!input.name || typeof input.name !== 'string') {
errors.push('Name is required');
}
if (typeof input.size !== 'number' || input.size <= 0) {
errors.push('Size must be a positive number');
}
return {
valid: errors.length === 0,
errors,
};
}
// Usage
figma.ui.onmessage = (msg) => {
const validation = validateCreateInput(msg);
if (!validation.valid) {
figma.ui.postMessage({
type: 'validation-error',
errors: validation.errors,
});
return;
}
// Proceed with valid input
};---
Utilities
Generate Unique Names
function generateUniqueName(baseName: string, existingNames: string[]): string {
if (!existingNames.includes(baseName)) {
return baseName;
}
let counter = 1;
let newName = `${baseName} ${counter}`;
while (existingNames.includes(newName)) {
counter++;
newName = `${baseName} ${counter}`;
}
return newName;
}
// Usage
const existingNames = figma.currentPage.children.map(n => n.name);
const newName = generateUniqueName('Frame', existingNames);Debounce
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: number | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
// Usage
const debouncedUpdate = debounce((selection: SceneNode[]) => {
figma.ui.postMessage({ type: 'selection', nodes: selection.map(n => n.name) });
}, 200);
figma.on('selectionchange', () => {
debouncedUpdate(figma.currentPage.selection);
});Clone Properties
// Copy visual properties from one node to another
function copyAppearance(
source: SceneNode & GeometryMixin,
target: SceneNode & GeometryMixin
): void {
if ('fills' in source && 'fills' in target) {
target.fills = [...source.fills];
}
if ('strokes' in source && 'strokes' in target) {
target.strokes = [...source.strokes];
target.strokeWeight = source.strokeWeight;
}
if ('effects' in source && 'effects' in target) {
target.effects = [...source.effects];
}
if ('opacity' in source && 'opacity' in target) {
target.opacity = source.opacity;
}
if ('cornerRadius' in source && 'cornerRadius' in target) {
(target as RectangleNode).cornerRadius = (source as RectangleNode).cornerRadius;
}
}Project Structure and Build Configuration
Setting up a Figma plugin project with manifest, TypeScript, and build tools.
Project Structure
Minimal Structure
my-plugin/
├── manifest.json # Plugin configuration
├── code.ts # Main thread code
├── ui.html # UI (optional)
└── package.json # DependenciesRecommended Structure
my-plugin/
├── manifest.json
├── package.json
├── tsconfig.json
├── esbuild.config.js # or webpack/vite config
│
├── src/
│ ├── code.ts # Main entry point
│ ├── ui.tsx # UI entry point (React)
│ ├── types.ts # Shared types
│ │
│ ├── features/ # Feature modules
│ │ ├── rename.ts
│ │ └── export.ts
│ │
│ └── utils/ # Utilities
│ ├── colors.ts
│ └── traversal.ts
│
├── ui/
│ ├── components/ # UI components
│ ├── hooks/ # React hooks
│ └── styles/ # CSS
│
└── dist/ # Build output
├── code.js
└── ui.html---
manifest.json
Minimal Manifest
{
"name": "My Plugin",
"id": "1234567890",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html",
"editorType": ["figma"]
}Complete Manifest
{
"name": "My Plugin",
"id": "1234567890123456789",
"api": "1.0.0",
"main": "dist/code.js",
"ui": "dist/ui.html",
"editorType": ["figma", "figjam"],
"capabilities": [],
"enableProposedApi": false,
"enablePrivatePluginApi": false,
"menu": [
{
"name": "Run Plugin",
"command": "run"
},
{ "separator": true },
{
"name": "Settings",
"command": "settings"
},
{
"name": "Help",
"command": "help"
}
],
"relaunchButtons": [
{
"command": "refresh",
"name": "Refresh",
"multipleSelection": true
}
],
"parameters": [
{
"name": "text",
"key": "text",
"description": "Text to insert",
"allowFreeform": true
}
],
"parameterOnly": false,
"documentAccess": "dynamic-page",
"networkAccess": {
"allowedDomains": ["api.example.com"],
"reasoning": "Fetch data from our API"
},
"codegenLanguages": [
{
"label": "React",
"value": "react"
}
],
"codegenPreferences": [
{
"itemType": "unit",
"propertyName": "unitType",
"label": "Unit Type",
"options": [
{ "label": "Pixels", "value": "px", "isDefault": true },
{ "label": "REM", "value": "rem" }
]
}
]
}Manifest Fields Reference
| Field | Required | Description |
|---|---|---|
name | Yes | Plugin name |
id | Yes | Unique plugin ID (assigned by Figma) |
api | Yes | API version |
main | Yes | Path to main code file |
ui | No | Path to UI HTML file |
editorType | No | ["figma"], ["figjam"], or both |
menu | No | Custom menu items |
relaunchButtons | No | Buttons that persist on nodes |
parameters | No | Quick action parameters |
documentAccess | No | "dynamic-page" for large docs |
networkAccess | No | Required for network requests |
Menu Commands
{
"menu": [
{ "name": "Create Frame", "command": "create-frame" },
{ "name": "Create Text", "command": "create-text" },
{ "separator": true },
{
"name": "Utilities",
"menu": [
{ "name": "Rename Layers", "command": "rename" },
{ "name": "Cleanup", "command": "cleanup" }
]
}
]
}// code.ts
figma.on('run', ({ command }) => {
switch (command) {
case 'create-frame':
createFrame();
break;
case 'create-text':
createText();
break;
case 'rename':
figma.showUI(__html__, { width: 300, height: 200 });
break;
default:
figma.showUI(__html__);
}
});---
TypeScript Setup
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["@figma/plugin-typings"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}tsconfig.ui.json (for UI with DOM)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2020", "DOM"],
"types": ["@figma/plugin-typings"],
"jsx": "react-jsx"
},
"include": ["src/ui.tsx", "ui/**/*"]
}Type Definitions
npm install --save-dev @figma/plugin-typings typescript---
Build Configuration
esbuild (Recommended)
// esbuild.config.js
const esbuild = require('esbuild');
const fs = require('fs');
// Build main thread code
esbuild.buildSync({
entryPoints: ['src/code.ts'],
bundle: true,
outfile: 'dist/code.js',
target: 'es2020',
format: 'iife',
});
// Build UI
esbuild.buildSync({
entryPoints: ['src/ui.tsx'],
bundle: true,
outfile: 'dist/ui.js',
target: 'es2020',
format: 'iife',
loader: {
'.tsx': 'tsx',
'.css': 'css',
},
});
// Inline JS into HTML
const uiJs = fs.readFileSync('dist/ui.js', 'utf8');
const uiCss = fs.readFileSync('ui/styles/main.css', 'utf8');
const uiHtml = `<!DOCTYPE html>
<html>
<head><style>${uiCss}</style></head>
<body>
<div id="root"></div>
<script>${uiJs}</script>
</body>
</html>`;
fs.writeFileSync('dist/ui.html', uiHtml);package.json Scripts
{
"scripts": {
"build": "node esbuild.config.js",
"watch": "node esbuild.config.js --watch",
"dev": "npm run watch",
"typecheck": "tsc --noEmit",
"lint": "eslint src/**/*.ts"
},
"devDependencies": {
"@figma/plugin-typings": "^1.0.0",
"esbuild": "^0.19.0",
"typescript": "^5.0.0"
}
}Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: {
ui: resolve(__dirname, 'src/ui.tsx'),
},
output: {
entryFileNames: '[name].js',
},
},
outDir: 'dist',
emptyOutDir: false,
},
});Webpack Configuration
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin');
const path = require('path');
module.exports = [
// Main thread
{
entry: './src/code.ts',
output: {
filename: 'code.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
},
// UI
{
entry: './src/ui.tsx',
output: {
filename: 'ui.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [
new HtmlWebpackPlugin({
template: './src/ui.html',
filename: 'ui.html',
inject: 'body',
}),
new HtmlInlineScriptPlugin(),
],
},
];Selection, Traversal, and Batch Operations
Patterns for working with node selections, tree traversal, and batch processing.
Selection Handling
Get Typed Selection
// Get all text nodes in selection
function getSelectedTextNodes(): TextNode[] {
return figma.currentPage.selection.filter(
(node): node is TextNode => node.type === 'TEXT'
);
}
// Get all nodes with fills
function getSelectedNodesWithFills(): (SceneNode & GeometryMixin)[] {
return figma.currentPage.selection.filter(
(node): node is SceneNode & GeometryMixin => 'fills' in node
);
}
// Get frames only
function getSelectedFrames(): FrameNode[] {
return figma.currentPage.selection.filter(
(node): node is FrameNode => node.type === 'FRAME'
);
}Selection Guard
function requireSelection(minCount: number = 1): SceneNode[] {
const selection = figma.currentPage.selection;
if (selection.length < minCount) {
figma.notify(`Please select at least ${minCount} item(s)`);
figma.closePlugin();
return [];
}
return [...selection];
}
function requireSingleSelection(): SceneNode | null {
const selection = figma.currentPage.selection;
if (selection.length !== 1) {
figma.notify('Please select exactly one item');
return null;
}
return selection[0];
}
// Usage
const nodes = requireSelection(1);
if (nodes.length === 0) return;
// Process nodes...Selection Change Listener
// Debounced selection handler
let selectionTimeout: number | null = null;
figma.on('selectionchange', () => {
if (selectionTimeout) clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = figma.currentPage.selection;
figma.ui.postMessage({
type: 'selection',
nodes: selection.map(node => ({
id: node.id,
name: node.name,
type: node.type,
})),
});
}, 100);
});---
Node Traversal
Recursive Children
// Get all descendants
function getAllChildren(node: SceneNode): SceneNode[] {
const children: SceneNode[] = [];
function traverse(n: SceneNode) {
children.push(n);
if ('children' in n) {
for (const child of n.children) {
traverse(child);
}
}
}
traverse(node);
return children;
}
// Get all descendants of a type
function findAllOfType<T extends SceneNode>(
node: SceneNode,
type: NodeType
): T[] {
const results: T[] = [];
function traverse(n: SceneNode) {
if (n.type === type) {
results.push(n as T);
}
if ('children' in n) {
for (const child of n.children) {
traverse(child);
}
}
}
traverse(node);
return results;
}
// Usage
const allText = findAllOfType<TextNode>(frame, 'TEXT');Walk Up (Find Parent)
// Find parent of type
function findParentOfType<T extends BaseNode>(
node: SceneNode,
type: NodeType
): T | null {
let current: BaseNode | null = node.parent;
while (current) {
if (current.type === type) {
return current as T;
}
current = current.parent;
}
return null;
}
// Find parent frame
function findParentFrame(node: SceneNode): FrameNode | null {
return findParentOfType<FrameNode>(node, 'FRAME');
}
// Find parent component
function findParentComponent(node: SceneNode): ComponentNode | null {
return findParentOfType<ComponentNode>(node, 'COMPONENT');
}Sibling Navigation
function getSiblings(node: SceneNode): SceneNode[] {
const parent = node.parent;
if (!parent || !('children' in parent)) return [];
return [...parent.children];
}
function getNextSibling(node: SceneNode): SceneNode | null {
const siblings = getSiblings(node);
const index = siblings.indexOf(node);
return siblings[index + 1] || null;
}
function getPreviousSibling(node: SceneNode): SceneNode | null {
const siblings = getSiblings(node);
const index = siblings.indexOf(node);
return siblings[index - 1] || null;
}---
Batch Operations
Process with Progress
async function processWithProgress<T>(
items: T[],
processor: (item: T, index: number) => void | Promise<void>,
options?: { batchSize?: number; label?: string }
): Promise<void> {
const { batchSize = 50, label = 'Processing' } = options || {};
const total = items.length;
for (let i = 0; i < total; i += batchSize) {
const batch = items.slice(i, i + batchSize);
for (let j = 0; j < batch.length; j++) {
await processor(batch[j], i + j);
}
// Update UI with progress
figma.ui.postMessage({
type: 'progress',
current: Math.min(i + batchSize, total),
total,
label,
});
// Yield to Figma to prevent freezing
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Usage
await processWithProgress(
figma.currentPage.selection,
(node) => {
if ('fills' in node) {
node.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }];
}
},
{ label: 'Updating colors' }
);Undo-Friendly Batching
// Group changes for single undo
function batchChanges<T>(
nodes: SceneNode[],
transformer: (node: SceneNode) => void
): void {
// Figma automatically groups rapid changes
// Just process them quickly
for (const node of nodes) {
transformer(node);
}
}
// For very large batches, use commitUndo
async function batchChangesLarge<T>(
nodes: SceneNode[],
transformer: (node: SceneNode) => Promise<void>
): Promise<void> {
for (const node of nodes) {
await transformer(node);
}
// Changes are automatically grouped
}UI Architecture and Messaging
Building plugin user interfaces and inter-thread communication.
UI Architecture
┌─────────────────────────────────────────────────────────────┐
│ MAIN THREAD (code.ts) │
│ │
│ figma.showUI(__html__) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ UI IFRAME (ui.html) │ │
│ │ │ │
│ │ • Full browser environment │ │
│ │ • HTML, CSS, JavaScript │ │
│ │ • Can use React, Vue, Svelte, etc. │ │
│ │ • NO access to Figma API │ │
│ │ • Communicates via postMessage │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘Showing UI
Basic UI
// code.ts
figma.showUI(__html__); // __html__ is replaced with ui.html contents at build
// With options
figma.showUI(__html__, {
width: 400,
height: 300,
title: 'My Plugin',
visible: true,
position: { x: 100, y: 100 },
themeColors: true, // Use Figma's theme colors
});UI Options
interface ShowUIOptions {
width?: number; // Default: 300
height?: number; // Default: 200
visible?: boolean; // Default: true
position?: { x: number; y: number };
title?: string;
themeColors?: boolean; // Inject Figma CSS variables
}Resize UI
// From main thread
figma.ui.resize(500, 400);
// From UI (request main thread to resize)
parent.postMessage({
pluginMessage: { type: 'resize', width: 500, height: 400 }
}, '*');
// code.ts
figma.ui.onmessage = (msg) => {
if (msg.type === 'resize') {
figma.ui.resize(msg.width, msg.height);
}
};---
Message Communication
UI → Main Thread
<!-- ui.html -->
<script>
// Send message to main thread
function sendMessage(type, data) {
parent.postMessage({ pluginMessage: { type, ...data } }, '*');
}
// Examples
sendMessage('create-shape', { shape: 'rectangle', width: 100, height: 50 });
sendMessage('update-color', { color: '#FF5733' });
sendMessage('close');
</script>Main Thread → UI
// code.ts
// Send data to UI
figma.ui.postMessage({
type: 'selection-data',
nodes: figma.currentPage.selection.map(node => ({
id: node.id,
name: node.name,
type: node.type,
})),
});
// Send on selection change
figma.on('selectionchange', () => {
figma.ui.postMessage({
type: 'selection-changed',
count: figma.currentPage.selection.length,
});
});Receiving in UI
<script>
window.onmessage = (event) => {
const msg = event.data.pluginMessage;
if (!msg) return;
switch (msg.type) {
case 'selection-data':
renderNodes(msg.nodes);
break;
case 'selection-changed':
updateCount(msg.count);
break;
case 'error':
showError(msg.message);
break;
}
};
</script>Typed Messages
// shared/types.ts
export type MainToUI =
| { type: 'selection-changed'; count: number }
| { type: 'node-data'; node: SerializedNode }
| { type: 'error'; message: string }
| { type: 'styles-loaded'; styles: StyleData[] };
export type UIToMain =
| { type: 'create-shape'; shape: 'rectangle' | 'ellipse'; size: number }
| { type: 'apply-style'; styleId: string }
| { type: 'close' };
// code.ts
figma.ui.onmessage = (msg: UIToMain) => {
switch (msg.type) {
case 'create-shape':
// TypeScript knows shape and size exist
break;
}
};
// ui.ts
declare function postMessage(msg: UIToMain): void;---
Plain HTML/CSS/JS
Basic Structure
<!-- ui.html -->
<!DOCTYPE html>
<html>
<head>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
color: var(--figma-color-text);
background: var(--figma-color-bg);
padding: 12px;
}
.input-group {
margin-bottom: 12px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: 500;
}
input, select {
width: 100%;
padding: 8px;
border: 1px solid var(--figma-color-border);
border-radius: 4px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
}
input:focus, select:focus {
outline: none;
border-color: var(--figma-color-border-brand);
}
button {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
}
.btn-primary {
background: var(--figma-color-bg-brand);
color: white;
}
.btn-secondary {
background: var(--figma-color-bg-secondary);
color: var(--figma-color-text);
}
.btn-row {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
</style>
</head>
<body>
<div class="input-group">
<label for="name">Name</label>
<input type="text" id="name" placeholder="Enter name">
</div>
<div class="input-group">
<label for="size">Size</label>
<input type="number" id="size" value="100" min="1">
</div>
<div class="btn-row">
<button class="btn-secondary" id="cancel">Cancel</button>
<button class="btn-primary" id="create">Create</button>
</div>
<script>
const nameInput = document.getElementById('name');
const sizeInput = document.getElementById('size');
document.getElementById('create').onclick = () => {
parent.postMessage({
pluginMessage: {
type: 'create',
name: nameInput.value,
size: parseInt(sizeInput.value, 10),
}
}, '*');
};
document.getElementById('cancel').onclick = () => {
parent.postMessage({ pluginMessage: { type: 'close' } }, '*');
};
// Receive messages
window.onmessage = (event) => {
const msg = event.data.pluginMessage;
if (msg?.type === 'update') {
nameInput.value = msg.name || '';
}
};
</script>
</body>
</html>UI Patterns and Resources
Common UI patterns for Figma plugins and working with external resources.
Common UI Patterns
Loading State
<div id="loading" class="loading">
<div class="spinner"></div>
<p>Loading...</p>
</div>
<div id="content" class="hidden">
<!-- Main content -->
</div>
<style>
.hidden { display: none; }
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--figma-color-border);
border-top-color: var(--figma-color-bg-brand);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<script>
window.onmessage = (event) => {
const msg = event.data.pluginMessage;
if (msg?.type === 'ready') {
document.getElementById('loading').classList.add('hidden');
document.getElementById('content').classList.remove('hidden');
}
};
</script>Tabs
<div class="tabs">
<button class="tab active" data-tab="settings">Settings</button>
<button class="tab" data-tab="export">Export</button>
<button class="tab" data-tab="about">About</button>
</div>
<div class="tab-content active" id="settings">
<!-- Settings content -->
</div>
<div class="tab-content" id="export">
<!-- Export content -->
</div>
<div class="tab-content" id="about">
<!-- About content -->
</div>
<style>
.tabs {
display: flex;
border-bottom: 1px solid var(--figma-color-border);
margin-bottom: 12px;
}
.tab {
padding: 8px 16px;
background: none;
border: none;
cursor: pointer;
color: var(--figma-color-text-secondary);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab.active {
color: var(--figma-color-text);
border-bottom-color: var(--figma-color-bg-brand);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
<script>
document.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => {
// Update tabs
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Update content
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById(tab.dataset.tab).classList.add('active');
};
});
</script>Color Picker
<div class="color-picker">
<input type="color" id="color" value="#0066FF">
<input type="text" id="color-hex" value="#0066FF" maxlength="7">
</div>
<style>
.color-picker {
display: flex;
gap: 8px;
}
input[type="color"] {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--figma-color-border);
border-radius: 4px;
cursor: pointer;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 2px;
}
input[type="color"]::-webkit-color-swatch {
border-radius: 2px;
border: none;
}
</style>
<script>
const colorInput = document.getElementById('color');
const hexInput = document.getElementById('color-hex');
colorInput.oninput = () => {
hexInput.value = colorInput.value.toUpperCase();
};
hexInput.oninput = () => {
if (/^#[0-9A-Fa-f]{6}$/.test(hexInput.value)) {
colorInput.value = hexInput.value;
}
};
</script>Node List
<ul id="node-list" class="node-list"></ul>
<style>
.node-list {
list-style: none;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--figma-color-border);
border-radius: 4px;
}
.node-item {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
border-bottom: 1px solid var(--figma-color-border);
}
.node-item:last-child {
border-bottom: none;
}
.node-item:hover {
background: var(--figma-color-bg-hover);
}
.node-item.selected {
background: var(--figma-color-bg-selected);
}
.node-icon {
width: 16px;
height: 16px;
opacity: 0.6;
}
.node-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<script>
window.onmessage = (event) => {
const msg = event.data.pluginMessage;
if (msg?.type === 'nodes') {
renderNodes(msg.nodes);
}
};
function renderNodes(nodes) {
const list = document.getElementById('node-list');
// Note: In production, use DOM methods instead of innerHTML for security
list.textContent = '';
nodes.forEach(node => {
const li = document.createElement('li');
li.className = 'node-item';
li.dataset.id = node.id;
const icon = document.createElement('span');
icon.className = 'node-icon';
icon.textContent = getIcon(node.type);
const name = document.createElement('span');
name.className = 'node-name';
name.textContent = node.name;
li.appendChild(icon);
li.appendChild(name);
li.onclick = () => {
parent.postMessage({
pluginMessage: { type: 'select-node', id: li.dataset.id }
}, '*');
};
list.appendChild(li);
});
}
function getIcon(type) {
const icons = {
FRAME: '⬜',
TEXT: 'T',
RECTANGLE: '▢',
ELLIPSE: '○',
COMPONENT: '◇',
INSTANCE: '◆',
};
return icons[type] || '•';
}
</script>---
File Downloads
// code.ts - Export and send to UI
const bytes = await node.exportAsync({ format: 'PNG' });
figma.ui.postMessage({
type: 'download',
bytes: Array.from(bytes),
filename: `${node.name}.png`,
mimeType: 'image/png',
});<!-- ui.html -->
<script>
window.onmessage = (event) => {
const msg = event.data.pluginMessage;
if (msg?.type === 'download') {
downloadFile(msg.bytes, msg.filename, msg.mimeType);
}
};
function downloadFile(bytes, filename, mimeType) {
const blob = new Blob([new Uint8Array(bytes)], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
</script>---
External Resources
<!-- Load external fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Load external scripts (bundled is preferred) -->
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
<!-- Note: Be careful with external resources -->
<!-- - They require internet connection -->
<!-- - May slow down plugin load -->
<!-- - Bundle when possible for better UX -->React UI and Figma Theming
Using React for plugin UI and Figma's theme color system.
Using React
Setup with Create React App
# Using Figma plugin template
npx degit nicebook/figma-plugin-react-template my-plugin
cd my-plugin
npm installManual React Setup
// ui.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import './ui.css';
// Types
type Message =
| { type: 'selection-changed'; count: number }
| { type: 'node-data'; node: { name: string; type: string } };
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [size, setSize] = useState(100);
// Listen for messages from main thread
useEffect(() => {
const handler = (event: MessageEvent) => {
const msg = event.data.pluginMessage as Message;
if (!msg) return;
if (msg.type === 'selection-changed') {
setCount(msg.count);
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
// Send message to main thread
const postMessage = useCallback((message: any) => {
parent.postMessage({ pluginMessage: message }, '*');
}, []);
const handleCreate = () => {
postMessage({ type: 'create', name, size });
};
const handleClose = () => {
postMessage({ type: 'close' });
};
return (
<div className="container">
<p className="selection-info">
{count} items selected
</p>
<div className="input-group">
<label>Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="input-group">
<label>Size</label>
<input
type="number"
value={size}
onChange={(e) => setSize(parseInt(e.target.value, 10))}
/>
</div>
<div className="btn-row">
<button className="btn-secondary" onClick={handleClose}>
Cancel
</button>
<button className="btn-primary" onClick={handleCreate}>
Create
</button>
</div>
</div>
);
}
const root = createRoot(document.getElementById('root')!);
root.render(<App />);Custom Hook for Figma Messages
// hooks/useFigmaMessage.ts
import { useEffect, useCallback } from 'react';
type MessageHandler<T> = (message: T) => void;
export function useFigmaMessage<T>(handler: MessageHandler<T>) {
useEffect(() => {
const listener = (event: MessageEvent) => {
const msg = event.data.pluginMessage;
if (msg) {
handler(msg as T);
}
};
window.addEventListener('message', listener);
return () => window.removeEventListener('message', listener);
}, [handler]);
}
export function usePostMessage() {
return useCallback((message: any) => {
parent.postMessage({ pluginMessage: message }, '*');
}, []);
}
// Usage
function App() {
const [data, setData] = useState(null);
const postMessage = usePostMessage();
useFigmaMessage((msg) => {
if (msg.type === 'data') {
setData(msg.data);
}
});
return (
<button onClick={() => postMessage({ type: 'fetch-data' })}>
Fetch Data
</button>
);
}---
Figma Theme Colors
When themeColors: true, Figma injects CSS variables:
/* Available CSS variables */
:root {
/* Text */
--figma-color-text: /* primary text */;
--figma-color-text-secondary: /* secondary text */;
--figma-color-text-tertiary: /* tertiary text */;
--figma-color-text-disabled: /* disabled text */;
--figma-color-text-onbrand: /* text on brand color */;
--figma-color-text-onbrand-secondary: /* secondary text on brand */;
--figma-color-text-danger: /* error text */;
--figma-color-text-warning: /* warning text */;
--figma-color-text-success: /* success text */;
/* Backgrounds */
--figma-color-bg: /* primary background */;
--figma-color-bg-secondary: /* secondary background */;
--figma-color-bg-tertiary: /* tertiary background */;
--figma-color-bg-brand: /* brand background */;
--figma-color-bg-brand-hover: /* brand hover */;
--figma-color-bg-brand-pressed: /* brand pressed */;
--figma-color-bg-danger: /* danger background */;
--figma-color-bg-warning: /* warning background */;
--figma-color-bg-success: /* success background */;
--figma-color-bg-hover: /* hover state */;
--figma-color-bg-pressed: /* pressed state */;
--figma-color-bg-selected: /* selected state */;
/* Borders */
--figma-color-border: /* primary border */;
--figma-color-border-strong: /* strong border */;
--figma-color-border-brand: /* brand border */;
--figma-color-border-danger: /* danger border */;
/* Icons */
--figma-color-icon: /* primary icon */;
--figma-color-icon-secondary: /* secondary icon */;
--figma-color-icon-tertiary: /* tertiary icon */;
--figma-color-icon-brand: /* brand icon */;
--figma-color-icon-danger: /* danger icon */;
}Using Theme Colors
/* Automatically adapts to light/dark mode */
body {
background: var(--figma-color-bg);
color: var(--figma-color-text);
}
.card {
background: var(--figma-color-bg-secondary);
border: 1px solid var(--figma-color-border);
}
.btn-primary {
background: var(--figma-color-bg-brand);
color: var(--figma-color-text-onbrand);
}
.btn-primary:hover {
background: var(--figma-color-bg-brand-hover);
}
.error {
color: var(--figma-color-text-danger);
background: var(--figma-color-bg-danger);
}Related skills
FAQ
How do Figma plugins communicate between threads?
The main sandbox and iframe UI communicate via figma.ui.postMessage() and onmessage.
Which thread can call the Figma API?
Only the main sandbox thread has Figma API access; the iframe UI thread handles HTML/CSS/JS and external APIs.