--- name: framer-plugins description: > 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. user-invocable: true license: MIT metadata: author: fredm00n version: 1.4.0 verifiedAgainst: "@framer/plugin@4.0.1" --- # 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 framer.getActiveCollection() // → Promise (unmanaged) framer.getManagedCollections() // → Promise framer.getCollections() // → Promise framer.createManagedCollection() // → Promise ``` ### Canvas Methods (canvas mode) ```typescript framer.addImage(image) // NamedImageAssetInput | File → Promise 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 framer.addDetachedComponentLayers({ url, layout, attributes? }) // → Promise (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.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/@?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 `
` instead of `