The most persistent misconception about Figma plugin development is that it requires deep knowledge of the Figma desktop application’s internal architecture. In practice, a plugin is nothing more than a JavaScript file that runs inside a sandboxed iframe, paired with a manifest.json that tells Figma how to load it. If you have written a script that manipulates a web page’s DOM, you already know most of the mechanics. The Figma-specific parts — the API surface, the manifest fields, and the execution model — take about an afternoon to learn.
This guide builds a complete, working plugin from an empty directory. No scaffolding tools, no boilerplate generators, no prior Figma API experience required. You will end with a plugin that reads the user’s current selection, rearranges it into a grid, and exposes a simple UI — the same foundational pattern that most production plugins build upon.
What a Figma Plugin Is
Before writing code, it helps to understand the environment your code will run in. Figma executes plugin code in a sandboxed iframe with a limited DOM — there is no window in the traditional sense, no document, and no direct access to the main Figma document from the UI thread. Instead, your code interacts with the Figma document through a global figma object, which exposes the API for reading and modifying the scene graph.
There are two execution contexts in a plugin:
| Context | What Runs There | What It Can Access |
|---|---|---|
| Main thread | The code in main (specified in the manifest) | The full figma API — scene graph, styles, selection, file metadata |
| UI thread | The code in ui (an HTML file) | A real DOM, postMessage to the main thread, nothing else |
The two contexts communicate exclusively through figma.ui.postMessage() and window.parent.postMessage(). All scene mutations must happen in the main thread. The UI thread is for forms, previews, and anything that needs standard web rendering.
For this first plugin, the UI will be minimal — a single button. The heavy lifting happens in the main thread.
Step One: The Manifest
Every plugin needs a manifest.json in its root directory. This file tells Figma where to find your code, what the plugin’s name and ID are, and what capabilities it requests. Here is the minimal manifest for the plugin we are building:
{
"name": "Grid Arranger",
"id": "YOUR_PLUGIN_ID_HERE",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html",
"editorType": ["figma"],
"capabilities": [],
"enableProposedApi": false,
"documentAccess": "dynamic-page"
}
Two fields deserve explicit attention because they cause the most confusion among beginners.
The id field. This is not something you invent. When you create a new plugin via Figma’s desktop app (Plugins → Development → New Plugin), Figma generates a UUID and writes it into the manifest for you. If you manually copy a manifest from a tutorial and forget to update this field, Figma will refuse to load the plugin with a cryptic error. The ID links the local manifest to the plugin’s entry in the Figma Community and its persisted settings.
The main field. This points to the compiled JavaScript file. Figma does not execute TypeScript, JSX, or any other preprocessed format. It runs plain JavaScript. This means you need a build step — or you can write plain JavaScript directly. For this guide, we use TypeScript with the official Figma plugin typings, compiled to a single code.js file via esbuild. The build setup is minimal and worth the type safety the moment your plugin grows past about fifty lines.
Step Two: Setting Up the Build
Create your project directory and initialize it:
mkdir grid-arranger
cd grid-arranger
npm init -y
npm install --save-dev typescript esbuild @figma/plugin-typings
Create a tsconfig.json with the following settings:
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020", "DOM"],
"strict": true,
"typeRoots": ["./node_modules/@types", "./node_modules/@figma"]
}
}
Then add a build script to package.json:
{
"scripts": {
"build": "esbuild src/code.ts --bundle --outfile=code.js --target=es2020"
}
}
This compiles src/code.ts into a single code.js file with no external dependencies — Figma requires plugins to be fully self-contained, so any third-party library you use must be bundled in. esbuild handles that automatically with the --bundle flag.
The @figma/plugin-typings package provides type definitions for the global figma object, so the TypeScript compiler knows the shape of every API method you call. This catches a substantial class of errors at compile time — misspelled property names, wrong argument types, and calls to API methods that do not exist.
Step Three: Writing the Main Thread Code
The plugin’s core logic lives in src/code.ts. There are three parts to the flow:
- Read the selection — find what nodes the user currently has selected.
- Arrange them in a grid — reposition and resize the selected nodes.
- Show the UI — let the user confirm or cancel.
Here is the complete implementation:
// src/code.ts
// -- Types -- //
type GridOptions = {
columns: number;
spacing: number;
};
// -- Main entry point -- //
figma.showUI(__html__, { width: 320, height: 240 });
const selectedNodes = figma.currentPage.selection;
// -- Utility: arrange nodes in a grid -- //
function arrangeInGrid(nodes: readonly SceneNode[], options: GridOptions): void {
const { columns, spacing } = options;
if (nodes.length === 0) return;
const rows = Math.ceil(nodes.length / columns);
// Determine the maximum width and height across the selection
let maxWidth = 0;
let maxHeight = 0;
for (const node of nodes) {
if ('width' in node && 'height' in node) {
maxWidth = Math.max(maxWidth, node.width);
maxHeight = Math.max(maxHeight, node.height);
}
}
// Position each node in the grid
nodes.forEach((node, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
// Skip nodes that don't have a resize method (e.g., groups need special handling)
if ('resize' in node) {
node.x = col * (maxWidth + spacing);
node.y = row * (maxHeight + spacing);
}
});
}
// -- Handle messages from the UI -- //
figma.ui.onmessage = (message) => {
if (message.type === 'arrange') {
if (selectedNodes.length === 0) {
figma.notify('No nodes selected. Select at least one frame or shape.');
return;
}
const options: GridOptions = {
columns: message.columns ?? 4,
spacing: message.spacing ?? 16,
};
arrangeInGrid(selectedNodes, options);
figma.notify(`Arranged ${selectedNodes.length} nodes in a grid.`);
figma.closePlugin();
}
if (message.type === 'cancel') {
figma.closePlugin();
}
};
Three details in this code are worth understanding before you move on.
Detail one — __html__ is a global string. Figma injects the plugin’s ui.html file into your code as the __html__ globals when you build with the official plugin bundler (or with esbuild and an appropriate plugin). The figma.showUI() call takes this string, renders it in the UI thread, and displays the window. If you open your compiled code.js and search for __html__, you will find the entire contents of your ui.html inline as a string literal.
Detail two — 'width' in node is a type guard, not a runtime check. The Figma API has a union type for SceneNode, and not every node type has a width or resize method. Frames and rectangles do. Groups and boolean operations have a width property but no resize method — they get skipped in the position loop. The in operator narrows the TypeScript type within the if block, so the compiler knows node.resize exists when you call it.
Detail three — the selection is captured once at startup. figma.currentPage.selection is read at the moment the plugin code executes. If the user changes their selection while the plugin UI is open, the local selectedNodes variable still references the original selection. For a grid arranger, observing selection changes via figma.on('selectionchange') is a refinement worth adding later, but the initial version works fine with the snapshot.
Step Four: Building the UI
The UI thread is a standard HTML page. Create ui.html in your project root:
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Inter, sans-serif;
padding: 16px;
background-color: #1e1e1e;
color: #ffffff;
}
label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: #bbbbbb;
}
input {
width: 100%;
padding: 8px;
margin-bottom: 12px;
border-radius: 4px;
border: 1px solid #333333;
background-color: #2d2d2d;
color: #ffffff;
font-size: 14px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
button {
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 14px;
}
#arrange-btn {
background-color: #0d99ff;
color: #ffffff;
}
#cancel-btn {
background-color: transparent;
color: #bbbbbb;
}
</style>
</head>
<body>
<h3>Grid Arranger</h3>
<label for="columns">Columns</label>
<input type="number" id="columns" value="4" min="1" max="12" />
<label for="spacing">Spacing (px)</label>
<input type="number" id="spacing" value="16" min="0" max="200" />
<div class="actions">
<button id="cancel-btn">Cancel</button>
<button id="arrange-btn">Arrange</button>
</div>
<script>
document.getElementById('arrange-btn').onclick = () => {
const columns = Number(document.getElementById('columns').value);
const spacing = Number(document.getElementById('spacing').value);
parent.postMessage(
{ type: 'arrange', columns, spacing },
'*'
);
};
document.getElementById('cancel-btn').onclick = () => {
parent.postMessage({ type: 'cancel' }, '*');
};
</script>
</body>
</html>
The critical line is parent.postMessage({ type: 'arrange', columns, spacing }, '*'). This sends a message from the UI thread to the main thread. In the main thread, the figma.ui.onmessage handler receives it and acts.
The '*' target origin is intentional for Figma plugins. The iframe is sandboxed and the parent is always the Figma desktop app, so a wildcard origin is safe here. Do not copy this pattern into a normal web application — there, the wildcard is a security vulnerability. In the Figma context, it is the standard approach.
Step Five: Loading and Testing the Plugin
With both files in place, run the build:
npm run build
This produces code.js in your project root. Now open the Figma desktop app:
- In the editor, go to
Plugins → Development → New Plugin. - Click Choose an existing manifest and select your
manifest.json. - The plugin appears under
Plugins → Development → Grid Arranger.
Create a few rectangles on the canvas, select them, then run the plugin. You should see the UI window, enter column and spacing values, and click Arrange. Your selection snaps into a grid layout.
Beginner vs. Advanced: What This Guide Leaves Out
This plugin is functional but intentionally minimal. The following table contrasts what a beginner version does against the patterns advanced production plugins use:
| Aspect | Beginner Version (This Guide) | Advanced Version |
|---|---|---|
| Build tool | esbuild, single output file | esbuild with multiple entry points, SWC for faster compilation, source maps |
| Type safety | Strict TypeScript, basic type guards | Full discriminated unions on SceneNode subtypes, generics for node map helpers |
| Selection handling | Snapshot at plugin start | Live selectionchange listener, multi-page selection support |
| UI framework | Vanilla HTML + inline JS | React or Preact via a separate UI bundle |
| Error handling | figma.notify for the most common failure | Full try/catch blocks, rollback transactions when a mutation partially fails |
| Testing | None | Unit tests on pure logic, integration tests with mocked figma global |
The most consequential difference is the selection handling. A production plugin that rearranges nodes needs to react if the user changes their selection after opening the plugin. The advanced approach subscribes to selection changes and recomputes the preview. For a first plugin, the snapshot approach is fine — it gets you a working tool you can extend.
Common Failure Modes and How to Diagnose Them
The first time you run a Figma plugin, something will fail. The following list covers the most frequent issues beginners hit, with the exact symptom you will see and the fix.
Failure one — “Invalid manifest” error on load. This usually means the id field is wrong or missing. The manifest you copy from any source needs you to replace the placeholder ID with the one Figma generates when you create a new plugin entry point. The desktop app writes the correct ID automatically if you use the “Choose an existing manifest” flow — trust it more than your text editor.
Failure two — plugin loads but the UI never appears. Your figma.showUI() call is either missing, placed after an early return, or the ui.html file path in the manifest is wrong. Check that the ui field in your manifest matches the actual filename case-sensitively. On some operating systems, ui.html and UI.html are different files.
Failure three — “figma is not defined” or “figma.showUI is not a function”. You are running the plugin in an environment that does not expose the Figma API — typically, this happens when you open code.js directly in a browser for debugging. The figma global exists only when the file is executed as a plugin in the Figma app. Debugging main-thread code outside Figma requires a mock, which is the advanced technique covered in the CI/CD post.
Failure four — the resize method does not exist on the selected node. In TypeScript, this is a compile error only if you have type guards ('resize' in node). In plain JavaScript, it throws a runtime error. Groups, boolean operations, and some other node types lack a resize method — always check for its existence before mutating position and size.
When a Single File Is Not Enough
This guide’s plugin fits in two files. The moment your plugin grows beyond roughly two hundred lines of main-thread code, the single-file approach becomes a liability. The triggers are typically:
- You find yourself writing the same selection-filtering or node-traversal logic in multiple places.
- Your UI grows complex enough that maintaining inline HTML strings feels wrong.
- You need to test logic that does not depend on the
figmaglobal, and the current structure forces you to run everything through a global.
At that point, adopting a proper project structure — separate source directories for main and UI, shared types, a test suite with a mocked figma global — is the right investment. The reference posts on this site walk through setting up exactly that kind of pipeline and test infrastructure.
What to Build Next
The grid arranger demonstrates the core mechanics: reading the selection, mutating scene nodes, and communicating between the UI and main threads. From here, three natural extensions follow:
- Add a live preview that re-arranges nodes as the user drags the column slider, using a
selectionchangelistener and debounced grid computations. - Persist user preferences — columns and spacing — via
figma.clientStorageso the next run remembers the last values. - Handle groups and nested selections, which requires walking the node tree and computing bounding boxes rather than reading direct
widthandheightproperties.
None of these require learning a new concept. They require deepening the same three skills this guide exercises: reading the API surface accurately, communicating securely between the two threads, and validating assumptions about node types before mutating them.
Pick the smallest extension that annoys you most in daily use — a fixed grid is merely a starting point, so consider what single modification would make the tool usable for your actual workflow.