npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 of run() 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-kit

Requires 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:

  1. 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 throws X is not defined at runtime.
  2. Pass data as params. That is the supported way in. params is structured-cloned through postMessage, so keep it JSON-shaped.
  3. Return JSON-serializable values. You cannot return a SceneNode; return node.id and node.name. Figma's node objects do not survive the trip.
  4. Keep your bundler target modern (es2022+). This is the one that bites silently. With an older target, your build turns async/await into calls to a helper like __awaiter or _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. Set target: "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, or defaultValue when unset.
  • rawValue — stored value only, undefined when 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:

  1. 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 a globalThis flag so hot reloads don't stack listeners. Selection changes are forwarded to the iframe and fanned out to onFigmaSelectionChange subscribers.
  2. 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.
  3. Gates incoming messages on the configured Figma origins.
  4. 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