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 fromframer-plugin, which is now deprecated on npm — never use the old name. - Scaffolding:
npm create framer-plugin@latest(thecreate-framer-pluginscaffolder keeps its name and installs@framer/plugin) - Build: Vite +
vite-plugin-framer(name unchanged;framer-plugin-toolspackCLI 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)andframer.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 → usegetActiveManagedCollection()(the deprecation is in the d.ts; the CMS docs page still shows the old call in examples).
Starting a New Plugin
npm create framer-plugin@latest→ scaffolds Vite + React +framer.json+ a sampleApp.- Pick modes first (see table below). CMS plugins need
configureManagedCollection+syncManagedCollection; asset/insert plugins usecanvas(orimage/editImage). import "@framer/plugin/framer.css"and callframer.showUI(...)in auseLayoutEffect.- Decide storage early:
localStoragefor per-user secrets,pluginDatafor shared state (see the decision tree below).
Cloning an existing plugin to start a new one? Change
framer.json'sidto a fresh hex value. A duplicateidmakes Framer treat the two plugins as the same plugin — they share installation state,localStorageorigin, andpluginDatanamespace, which silently cross-contaminates dev. The scaffolder generates a uniqueid; preserve that property when copying a repo.
framer.json
Every plugin needs a framer.json at the project root:
{
"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), inpublic/. 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
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)
closePluginthrowsFramerPluginClosedErrorinternally — always ignore in catch blocksshowUIshould be called inuseLayoutEffectto avoid flicker
Properties
framer.mode— current mode string
Collection Access
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)
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. 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 byid) — and there is nogetItems(), onlygetItemIds().- Every
fieldDatavalue needs an explicit type wrapper:{ type: "string", value: "..." }. Raw values are silently ignored.
Permissions
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.
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. Passnullto delete.pluginDatais for small, bounded values. Anything that grows with content count (per-item delta-sync state, caches) will exceed 2kB/entry — keep it inlocalStorageand treat it as a rebuildable optimization, not shared truth (per-user, so collaborators rebuild it on first use).pluginDatais 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 priorframer.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:
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.
Code Files & Hosted Modules (canvas mode)
Verified against @framer/plugin v4 in a production canvas plugin (2026-06):
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, neverinsertURL. The identifier is stable across regenerations:local-module:codeFile/{fileId}:{exportName}.insertURLis version-pinned (…/Name-hash.js@{versionId}) and changes on everysetFileContent. - 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 becomesmodule:{moduleId}/{version}/{Name}.js:{export}(match on themodule:{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 whoseexportsstays[]while typecheck passes has an unsupported import — the build fails without an error surface. - Don't trust
@framerIntrinsicwidth/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"
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 — Full API signatures, node geometry, permission methods, type definitions
- cms-managed-collections.md — CMS plugins: ManagedCollection API, sync algorithm, field types, slugs, CMS pitfalls (skip for non-CMS plugins)
- patterns.md — Cross-cutting patterns: auth (OAuth / API key), UI sizing, progress, resilience, menus, canvas insertion
- pitfalls.md — Known gotchas, the project-remix/licensing pattern, debugging tips
- marketplace.md — Submission workflow, listing specs, review process, policies, post-publication obligations
Key Rules
Always
- Check the project's
CLAUDE.mdfor project-specific overrides and decisions. - Before building any new feature, check 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.
- Import
"@framer/plugin/framer.css"for native styling. - Use
<div role="button">instead of<button>to avoid Framer's CSS overrides. - Handle
FramerPluginClosedErrorin catch blocks — ignore it silently. - Call
showUIinuseLayoutEffectto avoid resize flicker. - Check
framer.isAllowedTo()(synchronous, returnsboolean) ADJACENT to every protected (mutating) call, wrap the call in try/catch with user-facing failure copy, and disable the triggering UI viauseIsAllowedTo— 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). - Use
localStoragefor per-user secrets,pluginDatafor 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 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.