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

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

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" }])
  • 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[]

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. Pass null to delete.
  • pluginData is 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 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 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, 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.

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 — non-compliance (non-English UI, no light/dark mode, ads, non-USD pricing, IP issues) means rejection during the ~3-week review.
  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) before any protected method.
  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.