| 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
npm create framer-plugin@latest → scaffolds Vite + React + framer.json + a sample App.
- Pick modes first (see table below). CMS plugins need
configureManagedCollection + syncManagedCollection; asset/insert plugins use canvas (or image/editImage).
import "@framer/plugin/framer.css" and call framer.showUI(...) in a useLayoutEffect.
- 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:
{
"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
framer.showUI({ position?, width, height, minWidth?, minHeight?, maxWidth?, resizable? })
framer.hideUI()
framer.closePlugin(message?, { variant: "success" | "error" | "info" })
framer.notify(message, { variant?, durationMs?, button?: { text, onClick } })
framer.setCloseWarning(message | false)
framer.setBackgroundMessage(message)
framer.setMenu([{ label, onAction, visible? }, { type: "separator" }])
framer.showContextMenu(items, { location })
framer.navigateTo(nodeId, { select?, zoom? })
framer.subscribeToOpenCodeFile(callback)
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
framer.getActiveManagedCollection()
framer.getActiveCollection()
framer.getManagedCollections()
framer.getCollections()
framer.createManagedCollection()
Canvas Methods (canvas mode)
framer.addImage(image)
framer.setImage(image)
framer.getImage()
framer.addText(text, options?)
framer.addSVG(svg)
framer.addComponentInstance({ url, attributes?, parentId? })
framer.addDetachedComponentLayers({ url, layout, attributes? })
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 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
import { framer, useIsAllowedTo, type ProtectedMethod } from "@framer/plugin"
framer.isAllowedTo("ManagedCollection.addItems", "ManagedCollection.removeItems")
const canSync = useIsAllowedTo("ManagedCollection.addItems", "ManagedCollection.removeItems")
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. 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:
const { id: currentProjectId } = await framer.getProjectInfo()
if (storedProjectId !== currentProjectId) {
if (framer.isAllowedTo("setPluginData")) {
await framer.setPluginData(PD_LICENSE_KEY, null)
}
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 })
framer.getCodeFiles()
codeFile.setFileContent(code)
codeFile.typecheck()
codeFile.exports
framer.addComponentInstance({ url })
instance.setAttributes({ controls })
- 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"
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.md for 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
FramerPluginClosedError in catch blocks — ignore it silently.
- Call
showUI in useLayoutEffect to avoid resize flicker.
- 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).
- 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 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.