Medium plugin
Panel + inspector section + hotkey + plugin options, all living inside your app. The shape ~80% of internal plugins end up at.
The medium tier is where most in-project plugins stabilize. You have one panel, one inspector section that edits the active item, a hotkey for the power users, and config options passed in by the host app. Still no separate package, no build pipeline, no publishing.
This page assumes you've worked through Simple. The biggest jump from that page is the config-store pattern, which is how a lazy-loaded panel reads options the host app passed at registration time.
What you'll build
A "Captions" plugin that:
- Adds a sidebar panel listing caption presets.
- Adds an inspector section for
textitems — pick a preset to apply to the selected caption. - Registers a hotkey (
Shift+C) that inserts the default preset at the playhead. - Accepts
presetsanddefaultPresetIdas plugin options from the host app.
Project layout
Seven small files — none required outside the plugin folder. You can collapse a couple if you want
(presets.ts into plugin.ts, insert.ts into panel.tsx); we split them for clarity.
A tiny config store
The lazy-loaded panel can't receive plugin options as props. The pattern is a module-level config
holder: the plugin sets it once during onRegister, every component reads from it at runtime.
import type { TextStyle } from '@studio-dev/vsdk'
export interface CaptionPreset {
id: string
label: string
style: Partial<TextStyle> // shape of the SDK's TextStyle
}
export interface CaptionsConfig {
presets: CaptionPreset[]
defaultPresetId: string
}
let config: CaptionsConfig = {
presets: [],
defaultPresetId: '',
}
export function setConfig(next: CaptionsConfig): void { config = next }
export function getConfig(): CaptionsConfig { return config }That's the whole pattern. A getter, a setter, a module-scoped variable. Components import
getConfig() lazily; the plugin writes once in onRegister.
This is safe because plugin options don't change at runtime — they're set at provider mount and
immutable for the session. If you genuinely need reactive plugin config (e.g. the user toggles
a setting at runtime), store it under metadata in the bundle or in your own Zustand store
alongside the SDK's.
Built-in defaults
A small constant file. Real apps would tune these or pull them from your design system.
import type { CaptionPreset } from './config-store'
export const DEFAULT_PRESETS: CaptionPreset[] = [
{
id: 'minimal',
label: 'Minimal',
style: { fontSize: 48, color: '#ffffff', fontWeight: 500 },
},
{
id: 'tiktok',
label: 'TikTok',
style: { fontSize: 72, color: '#ffffff', fontWeight: 800, textTransform: 'uppercase' },
},
{
id: 'classic',
label: 'Classic',
style: { fontSize: 56, color: '#facc15', fontFamily: 'Inter' },
},
]Plugin definition
The plugin definition is where everything is wired up. Three registrations + one async config write:
import { createPlugin, type PluginDefinition } from '@studio-dev/vsdk/plugins'
import { lazy } from 'react'
import { DEFAULT_PRESETS } from './presets'
import { insertDefaultCaption } from './insert'
import type { CaptionPreset } from './config-store'
export interface CaptionsPluginOptions {
/** Caption presets the user can pick. Defaults to a built-in set. */
presets?: CaptionPreset[]
/** ID of the preset used by the Shift+C hotkey. Defaults to the first preset. */
defaultPresetId?: string
/** Sidebar slot order. Default 60. */
order?: number
}
export function captionsPlugin(options: CaptionsPluginOptions = {}): PluginDefinition {
return createPlugin({
id: 'app:captions',
name: 'Captions',
version: '0.1.0',
async onRegister(ctx) {
// Resolve options against defaults and stash for runtime.
const { setConfig } = await import('./config-store')
const presets = options.presets ?? DEFAULT_PRESETS
const defaultPresetId = options.defaultPresetId ?? presets[0]?.id ?? ''
setConfig({ presets, defaultPresetId })
// 1. Sidebar panel
ctx.registerPanel({
id: 'captions',
label: 'Captions',
icon: ctx.icons.itemCaptions,
component: lazy(() => import('./panel')),
order: options.order ?? 60,
})
// 2. Inspector section — appears only on text items
ctx.registerInspectorSection({
itemType: 'text',
label: 'Caption preset',
mode: 'inline',
order: 50,
component: lazy(() => import('./inspector')),
})
// 3. Hotkey — insert default preset at playhead
ctx.registerHotkey({
id: 'captions:insert-default',
key: 'c',
modifiers: ['shift'],
label: 'Insert default caption',
when: 'always',
onTrigger: () => insertDefaultCaption(ctx.store),
})
},
})
}Three new tools you didn't need in the simple tier:
| New here | What it does |
|---|---|
async onRegister | The SDK awaits this before rendering plugin UI, so the config store is populated by the time the panel mounts. |
ctx.registerInspectorSection | Adds a section to the inspector panel for a specific item type. mode: 'inline' renders inside the default tab; mode: 'tab' adds a dedicated tab. |
ctx.registerHotkey | Registers a keyboard shortcut. when scopes it: 'always', 'timeline-focused', 'player-focused'. |
The panel
'use client'
import { Button, useStudioStore } from '@studio-dev/vsdk'
import { useMemo } from 'react'
import { getConfig } from './config-store'
import { insertCaption } from './insert'
export default function CaptionsPanel() {
// Static read — config doesn't change at runtime.
const { presets } = useMemo(() => getConfig(), [])
// Narrow selectors keep the component cheap.
const tracks = useStudioStore((s) => s.timeline.tracks)
const items = useStudioStore((s) => s.timeline.items)
const itemIdsByTrack = useStudioStore((s) => s.timeline.itemIdsByTrack)
const settings = useStudioStore((s) => s.project.settings)
const currentFrame = useStudioStore((s) => s.player.currentFrame)
const addItem = useStudioStore((s) => s.addItem)
const addTrack = useStudioStore((s) => s.addTrack)
return (
<div className="flex h-full flex-col gap-2 p-3">
<p className="text-xs text-studio-text-tertiary">
Click to insert. Each preset becomes a new text item at the playhead.
</p>
{presets.map((p) => (
<Button
key={p.id}
variant="secondary"
className="justify-start"
onClick={() =>
insertCaption({
preset: p,
tracks, items, itemIdsByTrack, settings, currentFrame, addItem, addTrack,
})
}
>
{p.label}
</Button>
))}
</div>
)
}The inspector section
Inspector components receive the active item id as a prop. Read the item, mutate it via
updateItemProperties.
'use client'
import { Label, useStudioStore } from '@studio-dev/vsdk'
import { useMemo } from 'react'
import { getConfig } from './config-store'
export default function CaptionsInspector({ itemId }: { itemId: string }) {
const { presets } = useMemo(() => getConfig(), [])
const item = useStudioStore((s) => s.timeline.items[itemId])
const updateItemProperties = useStudioStore((s) => s.updateItemProperties)
if (!item) return null
const apply = (presetId: string) => {
const preset = presets.find((p) => p.id === presetId)
if (!preset) return
// Spread the existing style so user customisations survive.
updateItemProperties(itemId, { style: { ...item.style, ...preset.style } })
}
return (
<div className="flex flex-col gap-2 p-2">
<Label className="text-xs uppercase tracking-wide text-studio-text-tertiary">
Caption preset
</Label>
<div className="flex flex-wrap gap-1.5">
{presets.map((p) => (
<button
key={p.id}
onClick={() => apply(p.id)}
className="rounded border border-studio-border-subtle bg-studio-surface-raised px-2.5 py-1 text-xs hover:bg-studio-accent/15 hover:border-studio-accent/40"
>
{p.label}
</button>
))}
</div>
</div>
)
}A few things worth highlighting:
mode: 'inline'(set inplugin.ts) means the section renders inside the default tab on desktop. Usemode: 'tab'if the section is heavy enough to deserve its own tab (then add anicon).updateItemPropertiesis reversible — applying a preset is one ⌘Z step. No commands to write yourself.- Spread the existing style when applying a preset so user-edited fields aren't clobbered.
- Tailwind tokens like
bg-studio-surface-raised,border-studio-border-subtlecome from the SDK's design tokens — they swap with the theme automatically.
Shared insert logic
The panel and the hotkey both insert captions, so the logic lives in one place:
import {
createDefaultTransform,
findAvailableTrack,
type StudioStore,
type ProjectSettings,
type Track,
type TrackItem,
} from '@studio-dev/vsdk'
import { getConfig, type CaptionPreset } from './config-store'
interface InsertArgs {
preset: CaptionPreset
tracks: Track[]
items: Record<string, TrackItem>
itemIdsByTrack: Record<string, string[]>
settings: ProjectSettings
currentFrame: number
addItem: (item: Omit<TrackItem, 'id'> & { id?: string }) => string
addTrack: (track: Omit<Track, 'id'> & { id?: string }) => string
}
const CAPTION_DURATION_SECONDS = 4
export function insertCaption(args: InsertArgs): string {
const { preset, tracks, items, itemIdsByTrack, settings, currentFrame, addItem, addTrack } = args
const durationFrames = settings.fps * CAPTION_DURATION_SECONDS
const trackId =
findAvailableTrack(tracks, itemIdsByTrack, items, 'track', currentFrame, durationFrames)?.id
?? addTrack({
name: 'Captions',
kind: 'track',
order: tracks.length,
locked: false, muted: false, hidden: false,
})
return addItem({
type: 'text',
trackId,
startFrame: currentFrame,
durationFrames,
name: preset.label,
zIndex: 20,
locked: false, disabled: false, muted: false,
source: {},
transform: createDefaultTransform(settings),
style: { text: 'Sample text', ...preset.style },
filters: [],
metadata: { source: 'captions-plugin', presetId: preset.id },
})
}
/** Wrapper the hotkey calls with just the store handle. */
export function insertDefaultCaption(store: StudioStore): void {
const { presets, defaultPresetId } = getConfig()
const preset = presets.find((p) => p.id === defaultPresetId) ?? presets[0]
if (!preset) return
const s = store.getState()
insertCaption({
preset,
tracks: s.timeline.tracks,
items: s.timeline.items,
itemIdsByTrack: s.timeline.itemIdsByTrack,
settings: s.project.settings,
currentFrame: s.player.currentFrame,
addItem: s.addItem,
addTrack: s.addTrack,
})
}The hotkey handler calls insertDefaultCaption(ctx.store) directly — ctx.store is the same
Zustand store hooks read from inside React. Outside React you always use store.getState() to
read.
The entrypoint
export { captionsPlugin, type CaptionsPluginOptions } from './plugin'
export type { CaptionPreset, CaptionsConfig } from './config-store'Wire it up
'use client'
import { useMemo } from 'react'
import { StudioEditor, VideoStudioProvider } from '@studio-dev/vsdk'
import '@studio-dev/vsdk/styles.css'
import { captionsPlugin } from '@/vsdk-plugins/captions'
export default function Editor() {
const plugins = useMemo(
() => [
captionsPlugin({
presets: [
{ id: 'brand-a', label: 'Brand A', style: { fontSize: 64, color: '#ff0080' } },
{ id: 'brand-b', label: 'Brand B', style: { fontSize: 64, color: '#00d4ff' } },
],
defaultPresetId: 'brand-a',
}),
],
[],
)
return (
<VideoStudioProvider plugins={plugins} theme="dark">
<StudioEditor className="h-screen" />
</VideoStudioProvider>
)
}Now your editor has:
- A "Captions" panel in the sidebar listing two brand presets.
- An inspector tab appearing whenever the user selects a text item.
- A Shift+C hotkey that inserts the default brand preset.
- All of it persisted across save/load via
metadata.source.
What you can build with this template
This shape (panel + inspector + hotkey + options) is enough for almost any preset library plugin:
- A typography plugin — preset list, click to insert, inspector to swap, hotkey to insert default.
- An animation presets plugin — list animations, apply to selected item, hotkey for "apply default to all selected".
- A filters plugin — list filters, apply on click, inspector with sliders for fine-tuning.
- A brand kit plugin — colors / logos / fonts, apply to selection.
Recipe — "apply to all selected"
A common upgrade: the inspector currently mutates one item. Sometimes the user wants to apply a preset to every selected item. Use a batch so all mutations land as one undo step:
function applyToSelection(presetId: string) {
const state = useStudioStore.getState() // outside React — use the imperative store
const ids = [...state.selection.selectedItemIds]
if (ids.length === 0) return
state.startBatch()
for (const id of ids) {
const item = state.timeline.items[id]
if (!item || item.type !== 'text') continue
const preset = presets.find((p) => p.id === presetId)
if (!preset) continue
state.updateItemProperties(id, { style: { ...item.style, ...preset.style } })
}
state.endBatch('Apply caption preset to selection')
}startBatch / endBatch collapses every nested command into one entry on the undo stack.
Recipe — async plugin config
If your config needs to be fetched (a brand kit endpoint, feature flags, …), make onRegister
async:
async onRegister(ctx) {
const { setConfig } = await import('./config-store')
const remote = await fetch('/api/brand/captions').then((r) => r.json())
setConfig({ presets: remote.presets, defaultPresetId: remote.defaultId })
// …registrations follow as usual.
}The SDK awaits onRegister before rendering plugin UI, so the panel never mounts with empty config.
Recipe — disabling the inspector section conditionally
registerInspectorSection doesn't take an isVisible. The standard workaround is to early-return
null from the component when the item shouldn't see it:
export default function CaptionsInspector({ itemId }: { itemId: string }) {
const item = useStudioStore((s) => s.timeline.items[itemId])
// Only show on captions you created (tagged via metadata.source).
if (item?.metadata?.source !== 'captions-plugin') return null
// …rest
}The section is registered for itemType: 'text', but the runtime check narrows it further.
Common mistakes (and the fixes)
| Mistake | Fix |
|---|---|
| Panel re-renders on every frame | You read too much state. Replace useStudioStore((s) => s.player) with a narrow selector. |
| Config is empty when the panel mounts | You forgot await somewhere in onRegister, or you set the config after registering the panel. Set config first. |
| Hotkey fires while the user types | Set when: 'timeline-focused' instead of 'always', or the SDK's input-focus check will handle it for typing fields. |
| Inspector section never shows up | itemType mismatched. Use '*' to match every item type while debugging. |
| Two clicks land on the same track | Always call findAvailableTrack before addTrack. |
When to graduate to advanced
You'll outgrow the medium template when:
- The plugin needs data from a backend (asset browser, search, account info) — wrap it in an adapter the host app provides.
- You want drag-and-drop from panel to timeline instead of click-to-insert.
- You need a custom command for a mutation the store's actions don't cover.
- You want to subscribe to events for cross-cutting behavior (autosave, analytics, plugin coordination).
- You want to register your own icons for the panel and other UI surfaces.