# Framer Plugins

> Framer Plugin SDK expert. Use when building, debugging, or modifying Framer plugins. Covers plugin modes, UI, permissions, data storage, canvas insertion + code files, CMS/ManagedCollection sync, licensing, marketplace submission, and common pitfalls.

- Skill: `fredm00n/framer-plugins` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add fredm00n/framer-plugins`
- Raw SKILL.md: https://api.skillmd.com/api/skills/fredm00n/framer-plugins/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: fredm00n (https://skillmd.com/u/fredm00n)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/fredm00n/framer-plugins

---


# Framer Plugin Development Guide

You are an expert on the Framer Plugin SDK. Use this reference when building, debugging, or modifying Framer plugins. Always check the project's CLAUDE.md for project-specific overrides.

## Quick Reference

- **SDK package**: `@framer/plugin` (v4+, the Framer 3.0 release). Renamed from `framer-plugin`, which is now **deprecated** on npm — never use the old name.
- **Scaffolding**: `npm create framer-plugin@latest` (the `create-framer-plugin` scaffolder keeps its name and installs `@framer/plugin`)
- **Build**: Vite + `vite-plugin-framer` (name unchanged; `framer-plugin-tools` `pack` CLI also unchanged)
- **Base styles**: `import "@framer/plugin/framer.css"` (optional: `import "@framer/plugin/inter.css"` to load Inter alone)
- **Core import**: `import { framer } from "@framer/plugin"`
- **Dev workflow**: `npm run dev` → Framer → Developer Tools → Development Plugin

## SDK v4 (2026-06-16) and the recent v3.x additions

**v4.0 itself is mostly the rename** (version attributions verified against the official changelog 2026-07-13). Shipped in v4: the package rename `framer-plugin` → `@framer/plugin` (current stable `4.0.1`; the old package is deprecated on npm; migration is a mechanical find-replace — no commonly-used API was removed; peerDeps `react`/`react-dom` `^18.2.0`, `csstype ^3.1.1`), a **restyled `framer.css`** (Framer 3.0 design: new Inter character alternates, selection colors, input element styles — **re-check custom input/selection styling after upgrading**; new `@framer/plugin/inter.css` export loads Inter alone), `WebPageNode.clone()` / `DesignPageNode.clone()`, an optional filter on `getLocalizationGroups()`, new frame/text link attributes, and `setParent` accepting `UnknownNode`.

**Added during v3.x (2025 – early 2026) — newer than many published examples, none permission-gated:**
- `framer.navigateTo(nodeId, opts?)` (3.7.0) — selects + zooms a node into view (`opts: { select?=true, zoom? }`). Instance forms: `node.navigateTo()`, `collectionItem.navigateTo()`, `codeFile.navigateTo()`. Ideal right after an insert/replace.
- `framer.setMenu(items)` and `framer.showContextMenu(items, config)` (3.5.2) — global window-header menu + context menus anywhere in the plugin UI.
- `framer.subscribeToOpenCodeFile(cb)` (3.7.0) — fires when the user opens/switches code files.
- **Design Pages** (3.8.0): read/create/edit nodes on Design Pages via the same Nodes + Selection APIs.
- **CMS**: access **all** Collections (not just plugin-managed) and write to unmanaged collections (Plugins 3.0, Mar 2025); `framer.createManagedCollection()` (3.10.2); `framer.setCloseWarning()` (3.10.3). `framer.getManagedCollection()` is **deprecated** → use `getActiveManagedCollection()` (the deprecation is in the d.ts; the CMS docs page still shows the old call in examples).

## Starting a New Plugin

1. `npm create framer-plugin@latest` → scaffolds Vite + React + `framer.json` + a sample `App`.
2. **Pick modes first** (see table below). CMS plugins need `configureManagedCollection` + `syncManagedCollection`; asset/insert plugins use `canvas` (or `image`/`editImage`).
3. `import "@framer/plugin/framer.css"` and call `framer.showUI(...)` in a `useLayoutEffect`.
4. Decide storage early: `localStorage` for per-user secrets, `pluginData` for shared state (see the decision tree below).

> **Cloning an existing plugin to start a new one?** Change `framer.json`'s `id` to a fresh hex value. A duplicate `id` makes Framer treat the two plugins as the *same* plugin — they share installation state, `localStorage` origin, and `pluginData` namespace, which silently cross-contaminates dev. The scaffolder generates a unique `id`; preserve that property when copying a repo.

## framer.json

Every plugin needs a `framer.json` at the project root:

```json
{
  "id": "6bbb4f",
  "name": "My Plugin",
  "modes": ["configureManagedCollection", "syncManagedCollection"],
  "icon": "/icon.svg"
}
```

- `id` — unique hex identifier (auto-generated by scaffolding)
- `modes` — array of supported modes (see below)
- `icon` — 30×30, SVG or bitmap (supply bitmaps at 90×90 px), in `public/`. SVGs need careful centering.

## Plugin Modes

| Mode | Purpose | `framer.mode` value |
|------|---------|---------------------|
| `canvas` | General-purpose canvas access | `"canvas"` |
| `configureManagedCollection` | CMS: first-time setup / field config | `"configureManagedCollection"` |
| `syncManagedCollection` | CMS: re-sync existing collection | `"syncManagedCollection"` |
| `image` | User picks an image | `"image"` |
| `editImage` | Edit existing image | `"editImage"` |
| `collection` | Access user-editable collections | `"collection"` |
| `code` | Edit the currently open code file | `"code"` |
| `localization` | Edit the active locale | `"localization"` |

CMS plugins use both `configureManagedCollection` + `syncManagedCollection`. (The v4 d.ts `Mode` union also carries an undocumented `api` value — ignore it unless the docs pick it up.)

## Core framer API

### UI Management

```typescript
framer.showUI({ position?, width, height, minWidth?, minHeight?, maxWidth?, resizable? })
framer.hideUI()
framer.closePlugin(message?, { variant: "success" | "error" | "info" })  // returns never
framer.notify(message, { variant?, durationMs?, button?: { text, onClick } })
framer.setCloseWarning(message | false)  // warn before closing during sync
framer.setBackgroundMessage(message)     // status while plugin runs hidden
framer.setMenu([{ label, onAction, visible? }, { type: "separator" }])
framer.showContextMenu(items, { location })        // context menu inside the plugin UI (3.0)
framer.navigateTo(nodeId, { select?, zoom? })      // select + zoom a node into view (3.0; not gated)
framer.subscribeToOpenCodeFile(callback)           // fires on code-file open/switch (3.0)
```

- `closePlugin` throws `FramerPluginClosedError` internally — always ignore in catch blocks
- `showUI` should be called in `useLayoutEffect` to avoid flicker

### Properties

- `framer.mode` — current mode string

### Collection Access

```typescript
framer.getActiveManagedCollection()    // → Promise<ManagedCollection>
framer.getActiveCollection()           // → Promise<Collection> (unmanaged)
framer.getManagedCollections()          // → Promise<ManagedCollection[]>
framer.getCollections()                // → Promise<Collection[]>
framer.createManagedCollection()       // → Promise<ManagedCollection>
```

### Canvas Methods (canvas mode)

```typescript
framer.addImage(image)                // NamedImageAssetInput | File → Promise<void>
framer.setImage(image)                // sets on the current selection
framer.getImage()
framer.addText(text, options?)
framer.addSVG(svg)                     // SVGData; max 10kB
framer.addComponentInstance({ url, attributes?, parentId? })  // → Promise<ComponentInstanceNode>
framer.addDetachedComponentLayers({ url, layout, attributes? })  // → Promise<FrameNode> (inlines the layers)
framer.getSelection()
framer.subscribeToSelection(callback)
```

`addComponentInstance` resolves to the **created node** — keep it; you need its id for selection-aware replace/geometry-copy flows. `addImage`/`addSVG`/`addText` resolve to `void`.

## CMS / ManagedCollection (pointer)

CMS plugins are their own world — the full surface (ManagedCollection API, field types, the silent-sync algorithm, user-editable fields, slug generation, field-data type wrappers, CMS pitfalls) lives in **[cms-managed-collections.md](references/cms-managed-collections.md)**. Read it when the plugin syncs an external data source into a Framer collection; skip it otherwise.

Two things to carry even if you never open that file:
- `ManagedCollection.addItems()` is an **upsert** (matched by `id`) — and there is **no `getItems()`**, only `getItemIds()`.
- Every `fieldData` value needs an explicit type wrapper: `{ type: "string", value: "..." }`. Raw values are silently ignored.

## Permissions

```typescript
import { framer, useIsAllowedTo, type ProtectedMethod } from "@framer/plugin"

// Imperative check
framer.isAllowedTo("ManagedCollection.addItems", "ManagedCollection.removeItems")

// React hook (reactive)
const canSync = useIsAllowedTo("ManagedCollection.addItems", "ManagedCollection.removeItems")

// Standard CMS sync permissions
const SYNC_METHODS = [
    "ManagedCollection.setFields",
    "ManagedCollection.addItems",
    "ManagedCollection.removeItems",
    "ManagedCollection.setPluginData",
] as const satisfies ProtectedMethod[]
```

**Only mutating methods are permission-gated.** Reads — `getItemIds`, `getPluginData`, `getActiveManagedCollection`, `getFields` — have *empty* arrays in the SDK `ProtectedMethod` table, so `isAllowedTo()` on them always returns `true`. Gate writes; wrap reads in `try/catch` for robustness instead of adding no-op read guards. (The docs' own phrasing: unprotected methods "mostly start with `get`, but not all" — when unsure, let TypeScript decide: if a name isn't accepted as a `ProtectedMethod`, it needs no gate.)

**Marketplace-reviewer contract (verdict-tested 2026-07):** the automated review wants, at EVERY protected call site, all three of: an explicit `framer.isAllowedTo()` check adjacent to the call (a reactive `useIsAllowedTo` early-return far upstream does not count on its own), try/catch around the call with clear user-facing failure copy (no silent no-ops), and UI disabled via `useIsAllowedTo` while the permission is missing. Ungated protected calls in dead/parked code get flagged too — delete them. Detail: [marketplace.md](references/marketplace.md).

## Data Storage Decision Tree

| Need | Use | Why |
|------|-----|-----|
| API keys, auth tokens | `localStorage` | Per-user, no size warnings, not shared |
| User preferences | `localStorage` | Per-user, synchronous |
| Data source ID, last sync time | `collection.setPluginData()` | Shared across collaborators, tied to collection |
| Project-level config | `framer.setPluginData()` | Shared, but 4kB total limit |
| Large / growing state (per-item sync maps, caches) | `localStorage` | `pluginData`'s 2kB/entry cap blows up as the map grows; `localStorage` holds ~MBs |
| License / entitlement state | Your backend, keyed on `framer.getCurrentUser().id` | The user id (64-char hex) is stable per account across projects/devices; nothing client-side to leak, share, or carry into a project remix. Strongest option for paid plugins — see pitfalls.md |

- `pluginData`: 2kB per entry, 4kB total. Strings only. Pass `null` to delete.
- **`pluginData` is for small, bounded values.** Anything that grows with content count (per-item delta-sync state, caches) will exceed 2kB/entry — keep it in `localStorage` and treat it as a rebuildable optimization, not shared truth (per-user, so collaborators rebuild it on first use).
- `pluginData` is **namespaced per plugin** — other plugins (and external agent tooling) cannot read or write your keys.
- `localStorage`: Sandboxed per-plugin origin, persists across all projects, but per-browser/device — soft friction only, never enforcement.
- `setPluginData()` (and other protected methods) surface an "Invoking protected message type" toast when called **without** a prior `framer.isAllowedTo()` check. It's the permission system, not a bug — gate the call and the toast disappears.

### Licensed plugins & project remix

`pluginData` is **copied verbatim when a project is remixed** — license keys and credentials carry into the new project and look valid. Detect the remix and clear stale credentials on load:

```typescript
const { id: currentProjectId } = await framer.getProjectInfo()
if (storedProjectId !== currentProjectId) {
    // Remixed → wipe credentials so the new owner does a fresh activation
    if (framer.isAllowedTo("setPluginData")) {
        await framer.setPluginData(PD_LICENSE_KEY, null)
        // ... clear any other project-bound keys
    }
    setLicenseActive(false)
}
```

Store the project ID at activation, compare every load; skip the clear silently if `isAllowedTo` is false (it retries next load). Full pattern + provider notes in [pitfalls.md](references/pitfalls.md#licensed-plugins--project-remix).

## Code Files & Hosted Modules (canvas mode)

Verified against @framer/plugin v4 in a production canvas plugin (2026-06):

```typescript
framer.createCodeFile(name, code, { editViaPlugin?: boolean })
// editViaPlugin: true → the file's "Edit Code" action opens this plugin instead of the editor
framer.getCodeFiles()                 // → Promise<CodeFile[]>
codeFile.setFileContent(code)         // new version; UPDATES already-inserted canvas instances in place
codeFile.typecheck()                  // TS diagnostics without leaving the plugin
codeFile.exports                      // [{ name, componentId, insertURL, type }]
framer.addComponentInstance({ url })  // accepts insertURL or any module URL
instance.setAttributes({ controls })  // rewrites property-control values in place, repeatable
```

- **Match canvas instances by `componentIdentifier`, never `insertURL`.** The identifier is stable across regenerations: `local-module:codeFile/{fileId}:{exportName}`. `insertURL` is version-pinned (`…/Name-hash.js@{versionId}`) and changes on every `setFileContent`.
- Code files can import each other relatively (`./Engine.tsx`) or by **absolute module URL** — the build pipeline keeps URL imports live.
- Every code file is publicly hosted at `https://framer.com/m/{Name}-{hash}.js[@version]` — **no auth, even from private projects**. Unpinned URLs track the latest save instantly (no publish step); pinned URLs are immutable and survive file deletion.
- `addComponentInstance({ url })` with another project's module URL works: the identifier becomes `module:{moduleId}/{version}/{Name}.js:{export}` (match on the `module:{moduleId}/` prefix), **no code file appears in the consumer project**, and source updates surface as a manual "Update" button on the instance.
- `@framerDisableUnlink` (comment annotation above the component) blocks Edit Code/unlink on shared/hosted components. It cannot hide a *local* code file — those are always visible and editable in Assets > Code.
- **Code-file imports (verified 2026-07):** bare npm specifiers resolve ONLY for `react`, `react-dom`, `framer`, `framer-motion`; version-suffixed specifiers (`react@18`) are rejected. Everything else enters via URL imports — pin them (e.g. `https://esm.sh/<pkg>@<version>?external=react,react-dom`, externalizing react so Framer's runtime supplies its own). The URL is kept verbatim in the compiled module, so version-pinned insertURLs freeze their dependency tree forever. **Silent-failure signature:** a code file whose `exports` stays `[]` while typecheck passes has an unsupported import — the build fails without an error surface.
- **Don't trust `@framerIntrinsic` width/height on cross-project inserts (verified 2026-07):** `addComponentInstance({ url })` honors the annotation for only a subset of hosted modules, even with identical compiled metadata. If the inserted size matters, set it explicitly after insert: `node.setAttributes({ width: "600px", height: "400px" })`.

## Key Exports from "@framer/plugin"

```typescript
import { framer, useIsAllowedTo, FramerPluginClosedError } from "@framer/plugin"
import type {
    ManagedCollection, ManagedCollectionField, ManagedCollectionFieldInput,
    ManagedCollectionItemInput, FieldDataInput, FieldDataEntryInput,
    ProtectedMethod, Collection, CollectionItem
} from "@framer/plugin"
import "@framer/plugin/framer.css"
```

## Supporting References

For deeper information, see the companion files in this skill directory:

- **[api-reference.md](references/api-reference.md)** — Full API signatures, node geometry, permission methods, type definitions
- **[cms-managed-collections.md](references/cms-managed-collections.md)** — CMS plugins: ManagedCollection API, sync algorithm, field types, slugs, CMS pitfalls (skip for non-CMS plugins)
- **[patterns.md](references/patterns.md)** — Cross-cutting patterns: auth (OAuth / API key), UI sizing, progress, resilience, menus, canvas insertion
- **[pitfalls.md](references/pitfalls.md)** — Known gotchas, the project-remix/licensing pattern, debugging tips
- **[marketplace.md](references/marketplace.md)** — Submission workflow, listing specs, review process, policies, post-publication obligations

## Key Rules

**Always**
1. Check the project's `CLAUDE.md` for project-specific overrides and decisions.
2. Before building any new feature, check [marketplace.md](references/marketplace.md). The current official docs say plugins publish immediately with no review and frame the quality bar (English UI, light + dark mode, USD pricing, no ads, IP rules) as recommendations — but an automated post-publication code scan empirically enforces the security/permission subset within hours and can pull a live listing (verdict-tested 2026-07). Build to the recommendations as if they were requirements.
3. Import `"@framer/plugin/framer.css"` for native styling.
4. Use `<div role="button">` instead of `<button>` to avoid Framer's CSS overrides.
5. Handle `FramerPluginClosedError` in catch blocks — ignore it silently.
6. Call `showUI` in `useLayoutEffect` to avoid resize flicker.
7. Check `framer.isAllowedTo()` (synchronous, returns `boolean`) ADJACENT to every protected (mutating) call, wrap the call in try/catch with user-facing failure copy, and disable the triggering UI via `useIsAllowedTo` — the marketplace reviewer checks all three per call site. Reads aren't gated, so don't add no-op guards on them. Ship original sources + sourcemaps in the zip (see marketplace.md).
8. Use `localStorage` for per-user secrets, `pluginData` for shared state — and clear credentials on project-remix mismatch (see Licensed plugins above).

**Canvas / code-file plugins**
9. Match canvas instances by `componentIdentifier`, never `insertURL` (version-pinned, changes on every file save).
10. Generated/managed code files should pass `{ editViaPlugin: true }` so "Edit Code" routes users back to the plugin.
11. Don't render an in-UI titlebar — Framer's window chrome already shows the plugin name + close; a 1px `border-top` is enough separation.
12. When cloning a plugin repo, give `framer.json` a fresh `id` (duplicate ids share state).

**CMS plugins** — see [cms-managed-collections.md](references/cms-managed-collections.md) for detail:
13. Attempt silent sync in `syncManagedCollection` mode before showing UI.
14. `addItems()` is upsert; `fieldData` values need explicit `{ type, value }`; never remove-all + re-add; never include `userEditable` fields in `fieldData`.

