Skip to Content
Customising & extendingWriting a plugin

Writing a plugin

Start from the template

Plugins → Download starter template produces a working package. Install it with Choose folder…, then 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.

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": 1, // refused if newer than the host "main": "main.js", "panel": "panel.html", // optional "permissions": ["scene:read", "animation:write"] }

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

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)

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.

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.

It appears as the Plugins panel in the right-hand dock, tabbing alongside Effects and Graph, and can be floated or popped out like any other panel. Several plugins with panels share it as tabs.

The development loop

Generate the starter

Plugins → Download starter template.

Install it as a folder

Choose 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.

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
No network, in the worker or the panelLocked down before your module is imported
One bundled file for mainNo module resolution at install time
  • Plugins — the user-facing side
  • Keyframes — the time-axis rule applies to your writes too
Last updated on