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(v3.6+) - Scaffolding:
npm create framer-plugin@latest - Build: Vite +
vite-plugin-framer - Base styles:
import "framer-plugin/framer.css" - Core import:
import { framer } from "framer-plugin" - Dev workflow:
npm run dev→ Framer → Developer Tools → Development Plugin
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/PNG 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" |
CMS plugins use both configureManagedCollection + syncManagedCollection.
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" }])
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[]
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 |
pluginData: 2kB per entry, 4kB total. Strings only. Passnullto delete.pluginDatais namespaced per plugin — other plugins (and agent tooling like framer-dalton) 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 v3 (Unicorn Studio 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.
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 — non-compliance (non-English UI, no light/dark mode, ads, non-USD pricing, IP issues) means rejection during the ~3-week review.
- 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) before any protected method. - 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.