@xoxo-labs/figma-kit
v0.2.0
Published
Typed eval bridge and store-backed storage hooks for Figma plugins whose UI is a hosted web app.
Downloads
301
Maintainers
Readme
@xoxo-labs/figma-kit
Status
Internal tooling, shared publicly. We built this for our own Figma plugins and use it in production there. It's public because there's no reason for it not to be — but for now it evolves with our needs: expect breaking changes between 0.x minors, and issues/PRs may move at our pace. If you use it anyway, pin your version.
What it is
A Figma plugin normally ships all of its logic in the published sandbox bundle, so every change means a new plugin version and a review roundtrip.
This package implements the other arrangement: the published plugin is a frozen
~30-line shell, and all behavior ships from your web app over a typed eval
bridge. Your UI is a hosted page (Next, Vite, whatever) loaded into the plugin
iframe. When it needs the Figma document, it posts a function to the sandbox,
the shell evals it against the real figma API, and the JSON result comes back.
Consequences:
- You deploy plugin behavior the way you deploy your site. No version bump, no review, no "please update the plugin" message to your team.
- The sandbox bundle only changes when the shell itself changes — which is approximately never.
- The bridge is typed end to end: your function receives
PluginAPI, and the awaited return type ofrun()is your function's return type.
On top of the bridge, the package adds a small zustand-backed cache for the two places a plugin can persist state (the file, and the user's machine), exposed as React hooks that stay in sync across every component and across Figma-side edits.
npm i @xoxo-labs/figma-kitRequires React >= 18 as a peer dependency.
Quickstart
Sandbox side
src/plugin/code.ts — the entire published plugin:
import { installEvalHandler } from "@xoxo-labs/figma-kit/sandbox";
declare const SITE_URL: string;
installEvalHandler({ siteUrl: SITE_URL, width: 350, height: 500 });Build it with esbuild, injecting the origin of your deployed site:
esbuild src/plugin/code.ts --bundle --target=es2022 \
--outfile=src/plugin/dist/code.js \
--define:SITE_URL=\"https://your-app.example.com\"SITE_URL is both the page the plugin iframe navigates to and the origin gate
for incoming messages: anything from another origin is dropped. Point it at
http://localhost:3010 for local development (and list that in your manifest's
networkAccess.devAllowedDomains).
UI side
Create one kit for your app:
// lib/kit.ts
import { createFigmaKit } from "@xoxo-labs/figma-kit";
export const kit = createFigmaKit({ namespace: "myPlugin" });Then use it from a client component:
"use client";
import { kit } from "@/lib/kit";
export default function Page() {
kit.useFigmaAutoSync(); // once, near the root
const note = kit.useFileSetting({ key: "note", defaultValue: "" });
const theme = kit.useUserSetting({ key: "theme", defaultValue: "light" });
const { collections, refetch } = kit.useVariableCollections();
const renamePage = async (name: string) => {
// GOLDEN RULE: self-contained function, data comes in via params.
await kit.figmaAPI.run(
(figma, { name }) => {
figma.currentPage.name = name;
},
{ name },
);
};
return (
<input
value={note.value ?? ""}
onChange={(e) => void note.setValue(e.target.value)}
/>
);
}The golden rule
Every function you pass to figmaAPI.run is stringified with fn.toString()
and evaled inside the Figma sandbox. That sandbox shares nothing with your
page — not your modules, not your scope, not your bundle's runtime.
So:
- No imports, no closure variables. The function body may reference only
its own two parameters (
figma,params) and things it declares itself. A reference to anything else compiles fine and then throwsX is not definedat runtime. - Pass data as
params. That is the supported way in.paramsis structured-cloned throughpostMessage, so keep it JSON-shaped. - Return JSON-serializable values. You cannot return a
SceneNode; returnnode.idandnode.name. Figma's node objects do not survive the trip. - Keep your bundler target modern (es2022+). This is the one that bites
silently. With an older target, your build turns
async/awaitinto calls to a helper like__awaiteror_async_to_generator, which lives elsewhere in your bundle — outside the stringified function. The stringified code then references a helper that does not exist in the sandbox. Settarget: "es2022"(or later) in tsup/esbuild, and"target": "ES2022"in tsconfig. This package ships es2022 for exactly this reason.
// ✗ broken: `prefix` is a closure variable, `formatName` is an import
const prefix = "Page: ";
await kit.figmaAPI.run((figma) => {
figma.currentPage.name = formatName(prefix);
});
// ✓ fine
await kit.figmaAPI.run(
(figma, { name }) => {
figma.currentPage.name = name;
},
{ name: formatName("Page: ") },
);If a call goes unanswered — usually because the page is not actually running
inside Figma — run() rejects after timeoutMs (default 15s) with an error
quoting the start of the offending function.
Two channels: eval bridge vs message RPC
They look similar from the app (both are postMessage under the hood) and are
not interchangeable:
| | Eval bridge — kit.figmaAPI.run(fn, params) | Message RPC — kit.postToPlugin / kit.usePluginMessage |
| ------------------- | ---------------------------------------------------- | -------------------------------------------------------------- |
| Who owns the code | Your web app. The function is stringified and evaled in the sandbox | Your published sandbox. A handler you wrote and shipped |
| To change behavior | Deploy your site | Republish the plugin, and wait for review |
| Shape | Request/response, typed, awaited | Fire-and-forget both ways, correlate replies yourself |
| Needs sandbox code | No — installEvalHandler is all there is | Yes — installEvalHandler({ onMessage }) or your own figma.ui.onmessage |
| Use it for | Almost everything | The few things the bridge cannot do |
Reach for the RPC when the sandbox is the one holding state or driving time: long-running jobs that report progress, work started by a Figma-side event, or logic you deliberately keep out of the web app. Everything else belongs on the bridge, where it ships with your site.
// Sandbox (published once):
installEvalHandler({
siteUrl: SITE_URL,
onMessage: (message) => {
const { type, id } = message as { type?: string; id?: string };
if (type === "SANDBOX_INFO") {
figma.ui.postMessage({ type: "SANDBOX_INFO", id, data: { pageName: figma.currentPage.name } });
}
},
});
// App:
const { message } = kit.usePluginMessage<{ type: "SANDBOX_INFO"; data?: { pageName: string } }>(
"SANDBOX_INFO",
);
<button onClick={() => kit.postToPlugin({ type: "SANDBOX_INFO" })}>Refresh</button>;usePluginMessage(type) does two things: it listens for messages of that type
(origin-gated on your configured origins), and it sends one request of that
type on mount — the convention being that a request and its reply share a
name. That auto-request is what messageThrottleMs guards:
createFigmaKit({ namespace: "myPlugin", messageThrottleMs: { HEAVY_PAYLOAD: 1000 } });Without it, ten components mounting at once (or a hot reload) send ten requests
for the same expensive payload. With it, one per second per type; other types
are unaffected, and explicit postToPlugin calls are never throttled. The
counter lives on the kit instance, so two kits do not silence each other.
Selection
useFigmaAutoSync() also keeps a selection store filled, using the
selectionchange forwarder it injects into the sandbox — your published
plugin does not need to post anything:
import { useFigmaSelectionStore, useSingleSelection } from "@xoxo-labs/figma-kit";
const selection = useFigmaSelectionStore((s) => s.selection);
const { nodeId, nodeName, type, characters, pluginData } = useSingleSelection();Each entry is a FigmaSelection: {id, name, type, pluginData, characters}.
pluginData is every sharedPluginData key the node carries under your kit's
namespace; characters is set for TEXT nodes only.
useSingleSelection() returns {} unless exactly one node is selected, so
destructuring is always safe and every field is simply undefined otherwise.
App-specific reads — an id you stashed in pluginData, an enum you validate
with a schema — belong in a thin wrapper of your own around it; the kit hands
you pluginData untouched and does not guess at your keys.
The store also carries app payloads keyed to the current selection (jsx,
placeholderJsx, layerNames, componentsInSelection, linkedChildren) with
setters. The kit never writes those — they exist so a plugin whose sandbox
reports more than node ids can keep everything in one store. If your sandbox
posts its own selection message, handle it with usePluginMessage and call
setSelection yourself; it is the same store the kit fills.
Reads are ordered: overlapping refreshes (drag-selecting fires many changes)
cannot let a stale answer win. kit.refreshFigmaSelection() forces a re-read.
Storage semantics
| Hook | Backed by | Scope | Good for |
| ---------------- | ------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------- |
| useFileSetting | figma.root.getSharedPluginData under your namespace | Lives in the Figma file; travels to every collaborator, and to any plugin that knows the namespace | "Which variable collection does this file use", per-file config |
| useUserSetting | figma.clientStorage | Local to this user's machine, across all files | UI preferences: theme, last-opened tab, dismissed hints |
Both return the same shape:
const { value, rawValue, setValue, isLoaded, refetch } = kit.useFileSetting({
key: "collectionId",
defaultValue: "",
});value— stored value, ordefaultValuewhen unset.rawValue— stored value only,undefinedwhen unset. Use it to tell "unset" from "set to the default".setValue(next)— writes optimistically: the store updates (and every component reading that key re-renders) before the sandbox round trip. If the write fails it logs a warning, re-reads the true value, and rethrows.isLoaded— false until the first read resolves.refetch()— force a re-read.
Empty string and unset are the same thing: setValue("") stores "", and reads
normalize it back to undefined.
Values are cached in one module-level zustand store keyed by
file:${namespace}:${key} / user:${key}, so two components reading the same
key share one read and one value, and multiple kits with different namespaces
coexist without collision. Reads are deduped while in flight. Errors are
console.warned, never thrown at render, and this layer never shows toasts —
notify from your actions instead.
Auto-sync
kit.useFigmaAutoSync(), called once near your app root, keeps the cache honest
about changes made outside your UI. It does three things:
- Injects a selection listener into the sandbox at runtime. The published
shell registers no document events; the bridge adds
figma.on("selectionchange", …)after the fact, guarded by aglobalThisflag so hot reloads don't stack listeners. Selection changes are forwarded to the iframe and fanned out toonFigmaSelectionChangesubscribers. - Refetches everything on window focus, throttled to once per second. This is what covers edits made directly in Figma's own UI — renaming a variable collection, for instance — where no plugin-observable event exists.
- Gates incoming messages on the configured Figma origins.
- Fills the selection store on every such change (and once at startup), by
reading the selected nodes back over the bridge — one
run()per selection change. See Selection.
It is idempotent per kit instance and a no-op during SSR.
API reference
createFigmaKit(config)
config:
{ namespace: string; origins?: string[]; timeoutMs?: number; messageThrottleMs?: Record<string, number> }.
origins defaults to ["https://www.figma.com", "https://staging.figma.com"],
timeoutMs to 15000, messageThrottleMs to {} (no throttling). namespace
scopes file settings and the pluginData collected for selected nodes.
Returns:
| Export | Description |
| ----------------------------- | ------------------------------------------------------------------------------ |
| figmaAPI | The FigmaAPI instance bound to this kit's origins and timeout |
| useFileSetting({key, defaultValue?}) | File-scoped setting hook (namespace comes from the config, not the call) |
| useUserSetting({key, defaultValue?}) | Machine-local setting hook |
| useVariableCollections() | {collections, isLoaded, refetch} — local variable collections with their modes |
| useFigmaAutoSync() | Starts auto-sync. Call once near the app root |
| onFigmaSelectionChange(cb) | Subscribe to sandbox selection changes; returns an unsubscribe function |
| postToPlugin({type, data?, id?}) | Send a message to your sandbox's own handlers (message RPC, not the bridge) |
| usePluginMessage<T>(type, onMessage?) | {message} — listen for that type, and request it once on mount (per-type throttled) |
| refreshFigmaSelection() | Re-read the selection into the selection store |
| refetchAllFigmaData() | Re-read every loaded key, plus collections if they were loaded |
| readFileKey(key) | Non-hook read of a file setting (namespace pre-bound) |
| writeFileKey(key, value) | Non-hook write of a file setting. Does not update the store |
| readUserKey(key) | Non-hook read of a user setting |
| writeUserKey(key, value) | Non-hook write of a user setting. Does not update the store |
| fetchCollections() | Force a collections re-read into the store |
Package root (@xoxo-labs/figma-kit)
| Export | Description |
| ------------------------- | --------------------------------------------------------------------------- |
| createFigmaKit | The factory above |
| FigmaKitConfig | Its config type |
| FigmaKit | Return type of createFigmaKit |
| FigmaAPI | The bridge class. new FigmaAPI({origins?, timeoutMs?}), .run(fn, params?) |
| figmaAPI | Default FigmaAPI singleton (default origins and timeout) |
| FigmaAPIOptions | Constructor options type |
| DEFAULT_FIGMA_ORIGINS | ["https://www.figma.com", "https://staging.figma.com"] |
| DEFAULT_TIMEOUT_MS | 15000 |
| useFigmaPluginCheck() | {isIframe} — false in a plain browser tab, for a "open me inside Figma" fallback |
| useFigmaDataStore | The raw zustand store, if you need to read/subscribe directly |
| FigmaDataStore | Store shape: values, loaded, collections, collectionsLoaded |
| VariableCollectionOption, VariableCollectionMode | Shapes returned by useVariableCollections |
| useFigmaSelectionStore | The selection store (zustand). Usable with a selector or whole |
| useSingleSelection() | {node, nodeId, nodeName, frameName, type, characters, pluginData}, or {} unless exactly one node is selected |
| readFigmaSelection(api, namespace) | One-shot selection read over the bridge, without touching the store |
| FigmaSelection | {id, name, type, pluginData, characters} |
| FigmaSelectionState | Selection store shape (generic over your component payload type) |
| SingleSelection | Return type of useSingleSelection |
| PluginMessage, PostToPluginProps, MessageRPC | Message RPC types |
| UseSettingProps, UseSettingResult, SelectionListener | Hook types |
Sandbox entry (@xoxo-labs/figma-kit/sandbox)
| Export | Description |
| --------------------------- | -------------------------------------------------------------- |
| installEvalHandler(opts) | Shows the UI and installs the EVAL message handler |
| InstallEvalHandlerOptions | {siteUrl, width?, height?, onMessage?} (defaults 350 × 500) |
onMessage receives every non-EVAL message that passes the origin gate — this
is the sandbox half of the message RPC, for the couple of hand-written commands
you want alongside the bridge. See
Two channels.
License
MIT
