Skip to Content
Customising & extendingWriting a plugin

Writing a plugin

Start from a sample

The Add plugin button in the Plugins panel offers two, and they cover different halves of the API:

Menu itemWhat you get
Download starter templateA complete working plugin — a registered command, a keyframe write, and a panel
Download effect sample (blur)A two-pass Gaussian blur: multi-pass chaining, downsampled passes, and the generated uniform layout

Take the starter first. Unzip it, then Add plugin ▸ Install from a folder… and accept the permissions.

Iterate with the row’s Reload, which re-reads the folder and reinstalls without asking for consent again — unless the manifest has started asking for something new, which sends you back through the consent screen deliberately.

The file picker still opens on every Reload. A browser cannot re-read a directory without a gesture, so this is a platform limit rather than a missing feature — the consent screen is the part that is skipped.

Package format

    • plugin.json
    • main.js
    • panel.html

Zipping the folder is fine — one wrapping directory is stripped automatically.

LimitValue
Per file2 MB
Per package8 MB
File count200
ExtensionsText only
Paths containing ..Refused at the format level

main is loaded as one file. Bundle your plugin if it has dependencies.

The manifest

plugin.json
{ "id": "studio.acme.easing-lab", // reverse-DNS, lowercase, unique "name": "Easing Lab", "version": "1.2.0", // semver "description": "…", // shown to the user before install "author": "Acme Studio", "homepage": "https://…", // http(s) only "apiVersion": 5, // the manifest GRAMMAR you use "main": "main.js", "panel": "panel.html", // optional "permissions": ["scene:read", "animation:write"], "requires": ["scene.read", "animation.write"], // what the host must DO "optional": ["webgpu"] }

Ask for the fewest permissions you need — the list is the install screen.

Two version numbers, and they move independently

FieldAnswers
apiVersionWhat manifest grammar you use. Checked against the host’s MANIFEST_VERSION, currently 5
requires / optionalWhat the host must be able to do

They used to be one number, which made every new host method look like a manifest change and told authors their manifests were out of date when nothing about them was. Bump apiVersion when you use a newer manifest field; use requires to say what the host must support.

Capability strings are additive and permanent — never renamed, never removed, never repurposed, because your manifest is signed and a string that changed meaning would silently change what you asked for:

scene.read · scene.write · scene.proxy · scene.batch · animation.read · animation.write · assets.read · assets.write · timeline · net.fetch · storage.global · storage.project · effects.single · effects.multipass · layerkinds · panels · wasm · webgpu

requires is checked at install, and a refusal names the reason — a capability the host knows but this machine lacks (“needs WebGPU”) reads differently from one no version ever had, which is a typo.

Put webgpu in requires if your plugin is only effects. On the WebGL2 tier a plugin effect renders its input unchanged, so the plugin is not degraded, it is inert — refusing to install is a better answer than looking healthy and doing nothing. Put it in optional instead if effects are a bonus, and feature-detect.

Omitting requires is not “I need nothing”. A manifest without one is treated as needing whatever its apiVersion implied before capabilities existed, so every already-published plugin keeps working. Write one when you want a specific answer.

The entry module

main.js
export function activate(motion) { motion.commands.register( { id: 'bounce', label: 'Bounce selection', icon: 'zap', needsSelection: true }, async ({ selection }) => { const t = await motion.timeline.getTime() for (const id of selection) { await motion.animation.setKeyframes(id, 'y', [ { t, value: 0, easing: 'easeOut' }, { t + 0.18, value: -60, easing: 'easeIn' }, { t + 0.42, value: 0, easing: 'easeOut' }, ]) } await motion.ui.notify(`Bounced ${selection.length} layer(s)`, 'success') }, ) }

export default { activate } and export default function (motion) also work.

Every motion.* call returns a promise. It is a message to the editor, not a function call into it — your code is in another thread.

The API

motion.manifest // your own manifest motion.permissions / motion.has(p) // what the user actually granted motion.ui.notify(message, level) // info | success | warning | error motion.ui.openPanel() / closePanel() motion.ui.sendToPanel(data) / onPanelMessage(fn) motion.commands.register(spec, handler) // spec: { id, label, icon?, needsSelection? } motion.composition.get() // { name, width, height, fps, durationSeconds } motion.scene.getSelection() / setSelection(ids) motion.scene.getLayers() / getLayer(id) motion.scene.createLayer({ kind, name, x, y }) // shape | text | group | null motion.scene.setProperty(id, prop, value) motion.scene.renameLayer(id, name) / deleteLayer(id) motion.animation.getTracks(id) / sample(id, prop, time) motion.animation.setKeyframe(id, prop, time, value, easing) motion.animation.setKeyframes(id, prop, [{ t, value, easing }]) // prefer this motion.animation.removeKeyframe(id, prop, time) motion.animation.setExpression(id, prop, source) motion.timeline.getTime() / setTime(seconds) motion.scene.apply(mutations) // many mutations, one round trip, one undo motion.storage.global / motion.storage.project // get / set / delete, no permission motion.net.fetch(url, init) // only the hosts your manifest declared

Prefer setKeyframes over a loop of setKeyframe. The bulk API sorts once and notifies once; writing a generated track a keyframe at a time is quadratic, and is what used to freeze the app on imports.

scene.apply — many mutations, one round trip

Every motion.* call is a message across a thread boundary. A generator that creates forty layers pays forty of those, and lands forty entries on the undo stack. scene.apply takes the whole list, performs it inside one edit transaction, and gives the user one Ctrl/ + Z — which is what they expect, because they took one action.

Contributing an effect

An effect is data plus a shader body. You do not write JavaScript that runs in the frame loop, and you do not write the pipeline:

  • You declare typed parameters, which become ordinary keyframeable properties in the inspector.
  • You write one function in the shader language. The host writes the entry point, the uniform layout, the bindings and the draw call.
  • Multi-pass and downsampled passes are available as capabilities (effects.multipass), as is reading the original input alongside the previous pass.

The validator refuses a shader it cannot verify rather than compiling something that might sample out of bounds, and the generated layout is checked against a real GPU rather than trusted — a uniform block that disagrees with the shader is the failure mode that produces wrong colours silently, so it is tested rather than reasoned about.

Contributing a layer type

A layer type gives you a first-class layer that owns a generated subtree of ordinary layers. Two rules carry most of the design:

  1. Ask for scene:read + scene:proxy, not scene:write. The proxy grant reaches only your own children, and it is the difference between a consent screen that says “manages its own content” and one that says “can delete your layers”.
  2. Authored edits and generated content are different things. You are notified about a user’s hand edit and not about your own regeneration, so a user who tweaks one of your children keeps that tweak.

What a document stores is the subtree itself plus a provenance block naming your id and version. Without your plugin the layers are still there and still render — only regeneration is missing.

Degrading on a refused permission

Check rather than assume:

if (await motion.has('scene:write')) { // offer the feature } else { // hide it, and say why }

A refused call returns an error naming the missing permission, so failing loudly is also fine — what is not fine is doing nothing silently.

Panels

panel.html is plain HTML, run in the sandboxed frame with two globals:

motionPanel.send(data) // → your plugin's onPanelMessage motionPanel.onMessage(fn) // ← your plugin's sendToPanel

The panel talks to your plugin only. It has no access to the editor and no access to the network — inline <script> runs, fetch does not.

Where it lands

You declare what kind of panel it is; the host decides where it goes.

"panels": [ { "id": "main", "title": "Easing Lab", "entry": "panel.html", "placement": "sidebar", "icon": "graph-value" } ]
placementWhere it appears
shared (default)A tab inside the one Plugin Panels panel in the right inspector, shared with every other shared panel
sidebarIts own tab in the left sidebar, beside Scene, Assets and Library
inspectorIts own tab in the right inspector, beside Properties and Effects

Pick shared unless your panel is a place the user goes rather than a control they reach for. It costs no rail space.

sidebar and inspector require an icon — the rail shows glyphs, not titles. Names come from the editor’s icon set and are validated by the editor and the registry, so a typo is a publish error rather than a generic glyph you never notice.

A tab of your own is granted, not guaranteed. Each rail hands out a fixed number of plugin slots — 3 on the left, 2 on the right — and past that your panel is demoted to the shared host. It still opens, and motion.ui.openPanel() still reveals it; it just does not own a glyph.

Which happened is printed on your plugin’s row, so a demotion never reads as your plugin being broken. Write the panel so it works either way: there is deliberately no API to ask where you ended up, because there is nothing useful you could do differently.

Getting it on screen

motion.ui.openPanel() reveals your panel wherever it landed. The user can also reach it from Plugins ▸ Your plugin: Panel, or by clicking its rail tab — which, if you declared onPanel:<id> in activationEvents, is what starts your plugin in the first place.

The tab exists whenever your plugin is installed and enabled, running or not; the panel states its own status until the worker is up.

Do not build a “close me” control into your panel. There is no ✕ on a plugin panel or tab, and the tab will not go away: a panel belongs to the rail for as long as its plugin is installed and enabled. motion.ui.closePanel() switches away from a shared tab and does nothing at all to a tab of your own. Disabling or uninstalling is what removes it.

The development loop

Generate the starter

Add plugin ▸ Download starter template, then unzip it.

Install it as a folder

Add plugin ▸ Install from a folder…, accept the permissions.

Edit and reload

Change the code, press Reload on the row. The picker still opens — a browser cannot re-read a directory without a gesture — but the consent screen does not.

Watch the log

Each row has a log carrying your console.* output, every call the permission gate refused, and the crash that stopped it if it stopped. It is kept after the plugin dies, because that is when you need it, and it is capped at 200 lines so a logging loop cannot grow the host.

There is no DevTools in the packaged app, so this log is your console.

Publish it

When it works, see Publishing a plugin — namespace, signing key, visibility, and what a user sees on install.

When it will not start

The three failures you will actually hit, and what the row says:

SymptomCause
Stopped after 8 seconds, with a reasonactivate did not finish. Boot timeout — do slow work after activation, not during it
“Stopped responding”Two missed heartbeats, 4 seconds apart. Something blocked your event loop
A call returns an error naming a permissionThe user unticked it, or you never asked. Check motion.has(p) and degrade

Constraints to design around

ConstraintWhy
Boot must complete in 8 secondsBoot timeout
Never block the loopA missed heartbeat twice over terminates the worker
No DOM, no window, no localStorageWorker realm. Use motion.storage instead
No ambient networkDeclare your hosts and ask for net:fetch; the panel stays network-free regardless
One bundled file for mainNo module resolution at install time
WebAssembly is allowedInside the Worker, where it is subject to the same gates as everything else
Effects need WebGPUOn WebGL2 a plugin effect is inert — declare webgpu

Publishing

Publish from inside the editor, and choose who can see it. The package digest is pinned in the registry’s metadata before the bytes move, so a digest shipped with the package proves nothing and is not what is checked.

You can hide or withdraw your own plugin without asking anyone. If you rotate your signing key, pre-authorising the rotation is what distinguishes it from someone else signing as you — an unannounced key change is treated as the latter.

Last updated on