Agent Skills: Framer Plugin Development Guide

>

UncategorizedID: fredm00n/framerlabs/framer-plugins

Install this agent skill to your local

pnpm dlx add-skill https://github.com/fredm00n/framerlabs/tree/HEAD/skills/framer-plugins

Skill Files

Browse the full folder contents for framer-plugins.

Download Skill

Loading file tree…

skills/framer-plugins/SKILL.md

Skill Metadata

Name
framer-plugins
Description
>

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:

{
  "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" })  // 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

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 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"

// 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. 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) {
    // 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, 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

  1. Check the project's CLAUDE.md for project-specific overrides and decisions.
  2. 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.
  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 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.