Figma has become a standard tool for UI design teams for a few simple reasons: it’s free for individual use, runs online across platforms, supports real-time collaboration, and has an active community around it. In practice, that also makes its plugin ecosystem especially approachable for frontend developers.
Figma itself is a web service, and its desktop app is packaged with Electron. That background shows up clearly in the plugin model: if you already work with web technologies, plugin development feels familiar.
Anything that can become SaaS will eventually become SaaS.
How Figma plugins work
A Figma plugin uses a two-thread architecture.
The UI thread has normal web capabilities, but it cannot directly manipulate the Figma document. The main thread is the opposite: it can operate on Figma data through the Figma API, but it does not have the full browser environment—only JavaScript execution plus the Figma API. The two sides communicate through postMessage.
According to Figma’s official explanation, the main thread sandbox runs on a WebAssembly build of QuickJS, while the UI thread runs inside an iframe.
This split solves two problems at once: code stays isolated, and plugin logic can still work safely with Figma data.
There was also an earlier attempt to use the native web sandbox API Realms for main-thread isolation, but security issues led to a return to the QuickJS-based approach.
With that model in mind, a good way to understand plugin development is to build something small but useful. Here the example is a plugin called Placeholder, designed to let designers insert placeholder images into a Figma canvas quickly.
Defining the feature
Before writing code, the first step is to pin down the behavior.
The image service http://placeimg.com/ can generate placeholder images on demand. The idea is to build a Figma plugin that lets the user specify image dimensions and category, then insert the resulting image into the canvas as an image layer.
The plugin needs a panel where the user can configure width, height, category, and filter, along with a button to trigger insertion.
Project structure
A plugin project can be initialized directly inside the Figma client.
Besides the three templates available by default, Figma also provides an official sample repository:
https://github.com/figma/plugin-samples
TypeScript is the recommended choice for Figma plugin development, and the official type support is solid. If you start from the default template that includes a UI, you can install dependencies with npm i, run npm run build, and then launch the plugin to see the initial result.
.
├── README.md
├── code.js
├── code.ts
├── manifest.json
├── package-lock.json
├── package.json
├── tsconfig.json
└── ui.html
manifest.json
Structurally, a Figma plugin project looks a lot like any other JavaScript project. The key metadata lives in manifest.json, which effectively plays the role that package.json plays in a typical JS app.
A default manifest.json looks like this:
{
"name": "figma-placeimg",
"id": "1117699210834344763",
"api": "1.0.0",
"main": "code.js",
"editorType": [
"figma"
],
"ui": "ui.html"
}
The two fields that matter most are main and ui:
main: the entry file for the plugin’s main process sandbox.ui: the file that contains the plugin UI, which runs in an iframe. In practice, the UI file is injected as a string into the built-in Figma variable__html__, and the sandbox can create the iframe withfigma.showUI(__html__).
A detail that matters a lot during development is that Figma injects the contents of the UI file as a string into the main thread, similar to <iframe srcdoc="__html__" />. That means you cannot freely reference other local assets the way you would in a normal web app. Anything the plugin UI depends on has to be bundled or inlined into the final string.
The ui field can also point to multiple files. In that case, Figma injects a __uiFiles__ mapping object. manifest.json also supports a menu field for defining plugin menu items. If you do not want to build a UI at all, you can use parameters and operate the plugin through commands instead. More options are available in the Plugin Manifest documentation.

Building the UI
Once the architecture is clear, the first thing the plugin needs is a panel that opens when the user runs it. That panel will collect dimensions, category, and filter settings.
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/figma-plugin-ds.css">
<style>
.content { display: flex; }
.icon--swap { animation: rotate 1s linear infinite; }
.hide { display: none; }
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<div id="app">
<div class="field">
<label for="" class="label">请输入图片尺寸:</label>
<div class="content" style="padding-left: 10px;">
<div class="input">
<input type="input" class="input__field" placeholder="宽" name="width">
</div>
<div class="label" style="flex:0;">×</div>
<div class="input">
<input type="input" class="input__field" placeholder="高" name="height">
</div>
</div>
</div>
<div class="field">
<label for="" class="label">请选择图片分类:</label>
<div class="content">
<div class="radio">
<input id="radioButton1" type="radio" class="radio__button" value="any" name="category" checked>
<label for="radioButton1" class="radio__label">全部</label>
</div>
<div class="radio">
<input id="radioButton2" type="radio" class="radio__button" value="animals" name="category" >
<label for="radioButton2" class="radio__label">动物</label>
</div>
<div class="radio">
<input id="radioButton3" type="radio" class="radio__button" value="arch" name="category" >
<label for="radioButton3" class="radio__label">建筑</label>
</div>
<div class="radio">
<input id="radioButton4" type="radio" class="radio__button" value="nature" name="category" >
<label for="radioButton4" class="radio__label">自然</label>
</div>
<div class="radio">
<input id="radioButton5" type="radio" class="radio__button" value="people" name="category" >
<label for="radioButton5" class="radio__label">人物</label>
</div>
<div class="radio">
<input id="radioButton6" type="radio" class="radio__button" value="tech" name="category" >
<label for="radioButton6" class="radio__label">科技</label>
</div>
</div>
</div>
<div class="field">
<label for="" class="label">请选择图片滤镜:</label>
<div class="content">
<div class="radio">
<input id="radioButton7" type="radio" class="radio__button" value="none" name="filter" checked>
<label for="radioButton7" class="radio__label">正常</label>
</div>
<div class="radio">
<input id="radioButton8" type="radio" class="radio__button" value="grayscale" name="filter" >
<label for="radioButton8" class="radio__label">黑白照</label>
</div>
<div class="radio">
<input id="radioButton9" type="radio" class="radio__button" value="sepia" name="filter" >
<label for="radioButton9" class="radio__label">老照片</label>
</div>
</div>
</div>
<div class="field" style="padding:0 10px;">
<div id="create" class="icon-button" style="width: 100%;">
<div class="icon icon--image"></div>
<div class="type type--small type--medium type--inverse">插入</div>
</div>
<div class="icon-button loading hide" style="width: 100%;">
<div class="icon icon--swap"></div>
</div>
</div>
</div>

Figma’s documentation recommends the UI component library here:
https://github.com/thomas-lowry/figma-plugin-ds
Using it makes a plugin feel much closer to the native Figma experience.
Because the UI content has to be embedded into the HTML string, the stylesheet is pulled in through a CDN URL. The example is simple enough that there is no need for React or another framework, although Figma does provide a React template that can be used as a reference:
https://github.com/figma/plugin-samples/tree/master/webpack-react
Fetching the image
Once the UI exists, the next job is to retrieve the image and insert it into Figma.
This is where the split between the two threads becomes important. The main thread has no network capability, so the download must happen in the UI thread. After that, the raw image bytes can be passed back to the main thread with postMessage, where the actual Figma document operations happen.
The UI-side code looks like this:
<script>
async function loadImage(url) {
const resp = await fetch('http://localhost:3000/' + url);
const buffer = await resp.arrayBuffer();
return new Uint8Array(buffer);
}
document.getElementById('create').onclick = async (e) => {
const width = parseInt(document.querySelector('input[name="width"]').value);
const height = parseInt(document.querySelector('input[name="height"]').value);
const category = document.querySelector('input[name="category"]:checked').value;
const filter = document.querySelector('input[name="filter"]:checked').value;
const loading = document.querySelector('.icon-button.loading');
e.target.classList.add('hide');
loading.classList.remove('hide');
const imgBytes = await loadImage(`https://placeimg.com/${width}/${height}/${category}/${filter}`);
parent.postMessage({ pluginMessage: { type: 'insert', bytes: imgBytes, width: width, height: height } }, '*');
loading.classList.add('hide');
e.target.classList.remove('hide');
}
</script>
The UI thread is a normal web environment, so network requests made with XMLHttpRequest or fetch still run into cross-origin restrictions. The documented workaround is to put a server-side proxy in front of those requests.
A related question is what to do if the plugin does not have a visible UI panel at all. The documented approach is to create an iframe anyway by calling figma.ui.show() with visible: false, then use that hidden iframe to make the request.
// code.ts
function fetch(url, options) {
const html = `<script>
fetch(${url}, ${JSON.stringify(options)}).then(resp => resp.json()).then(resp => parent.sendMessage({
pluginMessage: { type: 'networkRequest', data: resp }
});
</script>`;
return new Promise(resolve => {
figma.ui.on('message', msg =>
msg.type === 'networkRequest' && resolve(msg.data)
);
figma.ui.show(html, { visible: false });
});
}
Inserting the image into Figma
Only the main thread can modify the document, so after the UI thread sends the bytes over, the rest of the work happens there.
At that point the logic is straightforward: create a rectangle node, create an image resource from the byte array, and use that image as the rectangle fill.
The inserted object can also be selected immediately and brought into view with figma.viewport.scrollAndZoomIntoView.
figma.ui.onmessage = msg => {
if (msg.type === 'insert') {
const rectNode = figma.createRectangle();
const image = figma.createImage(msg.bytes);
rectNode.name = 'Image';
rectNode.resize(msg.width, msg.height);
rectNode.fills = [{
imageHash: image.hash,
scaleMode: 'FILL',
scalingFactor: 0.5,
type: 'IMAGE'
}];
figma.currentPage.appendChild(rectNode);
figma.currentPage.selection = [rectNode];
figma.viewport.scrollAndZoomIntoView([rectNode]);
}
figma.closePlugin();
};
Besides explicitly calling figma.showUI to display the interface, a plugin should also explicitly call figma.closePlugin() when its work is done so Figma knows the session can end.
Making the workflow smoother
The first version works, but there is an obvious usability improvement.
A common design workflow is to draw a rectangle first as a placeholder, then replace it with an image later. Instead of forcing the user to manually re-enter the same width and height, the plugin can detect when a rectangle is selected, read its size, and use that as the default.
In the main thread, this can be done by listening to selectionchange:
// code.ts
function initSelectionState() {
if (figma.currentPage.selection.length === 1 && figma.currentPage.selection[0].type === 'RECTANGLE') {
const rectNode = figma.currentPage.selection[0];
figma.ui.postMessage({ type: 'update', width: rectNode.width, height: rectNode.height });
}
}
figma.on('selectionchange', initSelectionState);
initSelectionState();
Whenever the selection changes, the plugin can inspect the active node and send width and height to the UI. The UI then fills those values into the input fields:
window.onmessage = function(e) {
if (e.data.pluginMessage.type === 'update') {
document.querySelector('input[name="width"]').value = e.data.pluginMessage.width;
document.querySelector('input[name="height"]').value = e.data.pluginMessage.height;
}
}
The insertion logic can also be adjusted so that if a rectangle is already selected, the plugin uses that rectangle instead of always creating a new one:
let rectNode: RectangleNode;
if (figma.currentPage.selection.length === 1 && figma.currentPage.selection[0].type === 'RECTANGLE') {
rectNode = figma.currentPage.selection[0];
} else {
rectNode = figma.createRectangle();
}
// const rectNode = figma.createRectangle();
That small change removes several unnecessary steps and makes the plugin feel much more natural inside an actual design workflow.
Publishing the plugin
Once the core behavior is complete, the plugin can be published directly through Figma’s plugin management interface using the Publish action.
Figma plugins can be published in two different scopes. Like browser extensions, they can be published to the public community, or they can be published privately to one or more organizations. Organization publishing does not require review, but only people and files in that organization can use the plugin. Community publishing requires review by Figma.
Debugging and development workflow
Because plugin development is web-based, debugging is relatively easy. You can open the developer console with Command + Shift + I.
Where things become less convenient is hot reload.
Since UI resources need to be compiled into an HTML string, the feedback loop is not as smooth as normal web development. One workaround people use is a nested iframe approach.
The idea is simple: the plugin UI thread hosts another online page inside an inner iframe, and the outer UI thread acts as a message bridge between the main thread and that nested iframe. In effect, the UI portion becomes an online app again, which restores a more standard web development workflow and makes hot updates much easier.
That only solves hot reload for the UI thread, though. If the main thread changes, the plugin still has to be updated and rebuilt.
You can push the idea even further by turning the main thread into a shell and letting the iframe send business logic down dynamically. That would also avoid most main-thread update friction.
// ui.html
parent.postMessage({ pluginMessage: { type: 'MAIN_CODE', code: 'console.log(figma)' } });
// code.ts
figma.ui.onmessage = (msg) => {
msg.type === 'MAIN_CODE' && eval(msg.code);
}
Beyond the example
A placeholder-image plugin is only a small demonstration, but it covers the essential loop of Figma plugin development: reading Figma state, communicating between UI and main threads, making network requests through the UI side, and writing data back into the document.
Once those basics are in place, the same pattern can support much more practical tooling—exporting multiple image sizes quickly, publishing icons to npm automatically, and other workflow automation that saves design and frontend teams real time.
The full example code has also been published on GitHub for reference:
https://github.com/lizheming/figma-placeimg