Simple plugin
A single-file panel that drops items onto the timeline. Lives inside your app — no separate package, no build pipeline, no publishing.
This page is the fastest path from "I have the SDK mounted" to "I have my own panel in the
sidebar." The plugin lives inside your app's source tree — usually a single folder under
src/vsdk-plugins/. You don't publish it. You don't bundle it separately. You just import it and
hand it to <VideoStudioProvider plugins={[…]}>.
Looking to ship a plugin as an npm package so other teams can install it? Skip this page and head to Publishing a plugin. Otherwise, keep reading — this is the in-project pattern.
What you'll build
A "Stickers" panel that lists a few static images. Clicking one inserts it at the playhead, on a free track (a new one is created if needed). Roughly 100 lines of code, two files, zero new dependencies.
When you're done, your editor will have a new sidebar tab — your own — and the user can press Cmd+Z to undo the insert just like any built-in action.
Project layout
Plugins live wherever you want inside your app. The convention is a top-level vsdk-plugins/ folder
with one subfolder per plugin:
Three files, all local. No package.json, no tsup, no peer-dependency dance.
Step 1 — the plugin definition
import { createPlugin } from '@studio-dev/vsdk/plugins'
import { lazy } from 'react'
export function stickersPlugin() {
return createPlugin({
id: 'app:stickers', // namespace it with your app name (use 'mycompany:' if you prefer)
name: 'Stickers',
version: '0.1.0', // any string; just for debugging — not enforced by the SDK
onRegister(ctx) {
ctx.registerPanel({
id: 'stickers',
label: 'Stickers',
icon: ctx.icons.pluginElements, // reuse an SDK icon
component: lazy(() => import('./panel')), // panel chunk loads on demand
order: 50, // built-ins use 1–10
})
},
})
}Three things to lock in here:
createPluginis just a typed identity function. It returns its argument with thePluginDefinitionshape. You could writeexport const stickersPlugin = (): PluginDefinition => ({ … })and it would behave identically.React.lazythe panel. The panel only loads when the user opens the tab — saving startup cost. The SDK wraps lazy components in<Suspense>automatically.ctx.iconsis the resolved icon map, so if the host app overrodepluginElementsyour panel automatically picks up that override.
Step 2 — the panel
'use client'
import {
Button,
createDefaultTransform,
findAvailableTrack,
useIconMap,
useStudioStore,
} from '@studio-dev/vsdk'
const STICKERS = [
{ id: 'cat', label: '🐱 Cat', url: 'https://cdn.example.com/cat.png' },
{ id: 'dog', label: '🐶 Dog', url: 'https://cdn.example.com/dog.png' },
{ id: 'star', label: '⭐ Star', url: 'https://cdn.example.com/star.png' },
]
export default function StickersPanel() {
const icons = useIconMap()
const Plus = icons.add
// Pull narrow slices — one selector per piece of state. The component re-renders only when
// *these* values change.
const settings = useStudioStore((s) => s.project.settings)
const tracks = useStudioStore((s) => s.timeline.tracks)
const items = useStudioStore((s) => s.timeline.items)
const itemIdsByTrack = useStudioStore((s) => s.timeline.itemIdsByTrack)
const currentFrame = useStudioStore((s) => s.player.currentFrame)
const addItem = useStudioStore((s) => s.addItem)
const addTrack = useStudioStore((s) => s.addTrack)
const insert = (sticker: (typeof STICKERS)[number]) => {
const durationFrames = settings.fps * 3 // 3 seconds at project FPS
// Try to land on an existing free track first; create a new one if every track is busy.
const available = findAvailableTrack(
tracks, itemIdsByTrack, items, 'track', currentFrame, durationFrames,
)
const trackId = available?.id
?? addTrack({
name: `Sticker ${tracks.filter((t) => t.kind === 'track').length + 1}`,
kind: 'track',
order: tracks.length,
locked: false, muted: false, hidden: false,
})
addItem({
type: 'image',
trackId,
startFrame: currentFrame,
durationFrames,
name: sticker.label,
zIndex: 10,
locked: false, disabled: false, muted: false,
source: { assetUrl: sticker.url, thumbUrl: sticker.url },
transform: {
...createDefaultTransform(settings),
width: settings.width * 0.4,
height: settings.height * 0.4,
},
style: {},
filters: [],
metadata: { source: 'stickers-plugin', stickerId: sticker.id },
})
}
return (
<div className="flex flex-col gap-2 p-3">
{STICKERS.map((s) => (
<Button
key={s.id}
variant="secondary"
className="justify-start gap-2"
onClick={() => insert(s)}
>
<Plus className="size-4" />
{s.label}
</Button>
))}
</div>
)
}Things to internalize for every plugin you'll ever write:
| Pattern | Why |
|---|---|
| One selector per state slice | Re-renders are scoped to the value you actually use. Avoid useStudioStore((s) => s) like the plague — it re-renders on every frame during playback. |
findAvailableTrack before addTrack | Multi-clicks land on the same track instead of stacking on top of each other. |
createDefaultTransform(settings) | Returns a centered, full-canvas transform sized to the project. Override individual fields (width/height/rotation/opacity). |
metadata.source: 'stickers-plugin' | Lets you find your items later — analytics, cleanup, "show this inspector only on my items". The SDK preserves metadata across save/load but never reads it. |
| No imperative undo wiring | addItem already pushes a reversible command. Ctrl+Z just works. |
Step 3 — the entrypoint
export { stickersPlugin } from './plugin'A one-liner barrel file is the convention even for in-project plugins — it gives you a single import path you can later rename without touching the call site.
Step 4 — wire it into the editor
This is the only piece that touches your editor mount:
'use client'
import { useMemo } from 'react'
import { StudioEditor, VideoStudioProvider } from '@studio-dev/vsdk'
import '@studio-dev/vsdk/styles.css'
import { stickersPlugin } from '@/vsdk-plugins/stickers'
export default function Editor() {
// `useMemo` here is important: a new array literal would re-register every render.
const plugins = useMemo(() => [stickersPlugin()], [])
return (
<VideoStudioProvider plugins={plugins} theme="dark">
<StudioEditor className="h-screen" />
</VideoStudioProvider>
)
}Open the editor, look in the sidebar — there's "Stickers" with the icon you picked. Click it, click a sticker, watch it drop on the timeline at the current frame.
What you got for free
You didn't write any of these — and they all work:
- Undo / redo for the insert (one Ctrl+Z removes it cleanly).
- Persistence — your sticker's
assetUrlandmetadatasurviveonSave/loadBundle. - Move / trim / split the new item on the timeline.
- Mobile — the sidebar tab automatically becomes a bottom-sheet entry.
- Lazy loading — your panel module ships only when the user opens it.
- Theming — every Tailwind class you used reads the editor's design tokens, so light/dark mode swap with the rest of the UI.
Anatomy of an in-project plugin
The whole file lives next to your other React code. There's no build step beyond your app's normal bundler. The shape stays the same regardless of complexity:
plugin.ts — the createPlugin({ … }) call. Registers extension points in onRegister.
panel.tsx — the lazy-loaded UI surface. Reads state via hooks, writes via actions.
index.ts — barrel re-export.Add files as your plugin grows (inspector.tsx, hotkeys.ts, commands.ts, config.ts) — the
shape doesn't change.
A few small variations
Adding a hotkey too
Two lines extra in onRegister:
ctx.registerHotkey({
id: 'stickers:insert-cat',
key: 's',
modifiers: ['shift'],
label: 'Insert sticker (Cat)',
when: 'always',
onTrigger: () => {
const state = ctx.store.getState()
// …reuse `insert(STICKERS[0])` logic here
},
})when controls focus scope: 'always', 'timeline-focused', or 'player-focused'.
Adding a toolbar button
ctx.registerToolbarAction({
id: 'stickers:quick-insert',
label: 'Quick sticker',
icon: ctx.icons.add,
order: 80,
onClick: () => { /* same insert logic */ },
})Both registrations live happily next to registerPanel in the same onRegister.
Passing config from the host app
The simple-tier doesn't typically need this — your STICKERS list is right there in the panel file. But the moment a sibling component needs to read plugin config, jump to the medium-tier config-store pattern.
When to graduate to medium
Stay on this tier as long as:
- The plugin contributes one panel with static content (presets baked into the file).
- You don't need to mutate selected items.
- You don't need to take options from the host app.
Graduate to Medium when:
- You need an inspector section to edit the selected item's properties.
- You want config options passed in by the host app (preset list, defaults, slot order…).
- You want shared insert logic reused by both a panel button and a hotkey.
Common mistakes (and the fixes)
| Mistake | Fix |
|---|---|
| Panel re-renders on every playback frame | You selected too much state. Replace useStudioStore((s) => s) with narrow selectors. |
| Two clicks land on the same track | Use findAvailableTrack before falling back to addTrack. |
| New track appears with wrong order | Always set order: tracks.length — the SDK doesn't autoincrement. |
| Sticker has wrong size | Override width / height in transform. createDefaultTransform fills the whole canvas by default. |
| Plugin re-registers on every render | Move plugins={[...]} to a useMemo (or a module constant). The provider compares by identity. |
What's next
Medium plugin
Panel + inspector section + hotkey + config options. The shape ~80% of plugins end up at.
Advanced plugin
Adapter-backed I/O, custom commands, event subscriptions, drag & drop, custom icons.
Extension points
Reference for every registration surface — panel, inspector section, toolbar, context menu, hotkey, icons.
Authoring overview
What a VSDK plugin actually is, the lifecycle hooks, the PluginContext, the difference between in-project and published plugins, and how to package one if you go that route.
Medium plugin
Panel + inspector section + hotkey + plugin options, all living inside your app. The shape ~80% of internal plugins end up at.