Inspector section
Inline sections vs. tab-mode sections, scoping to item types, and the {itemId} prop contract.
An inspector section renders inside the right-hand properties panel when an item is selected. It sees the selected item's id, reads the item from the store, and mutates it through actions. Two modes: inline (folds into the default properties tab) or tab (becomes its own tab on desktop / item action button on mobile).
The registration shape
interface InspectorSectionConfig {
itemType: string // 'video' | 'audio' | 'image' | 'text' | 'overlay' | 'captions' | 'adjustment' | '*'
component: ComponentType<{ itemId: string }>
order: number
label: string
mode?: 'inline' | 'tab' // default 'inline'
icon?: ComponentType<{ className?: string }> // required when mode='tab'
mobileComponent?: LazyExoticComponent<...> | ComponentType<{ itemId: string }>
}Registering an inline section
Inline sections appear stacked in the default item-properties tab, sorted by order. Use them for
property controls that belong next to the item's other properties.
ctx.registerInspectorSection({
itemType: 'image',
label: 'Filters',
mode: 'inline',
order: 50,
component: lazy(() => import('./filter-controls')),
})Registering a tab section
Tab sections create a new top-level tab in the inspector (desktop) and an action button on the mobile bottom bar. Use them for substantial sub-views — like an entire animation editor — that deserve their own surface.
ctx.registerInspectorSection({
itemType: 'image',
label: 'Effects',
mode: 'tab',
order: 100,
icon: ctx.icons.sliders, // required for tabs
component: lazy(() => import('./effects-tab')),
mobileComponent: lazy(() => import('./effects-tab-mobile')),
})icon is mandatory for tab mode — it's the tab indicator on desktop and the button glyph on mobile.
Scoping with itemType
| Value | Renders when |
|---|---|
'image', 'video', etc. | An item of exactly that type is selected |
'*' | Any item is selected |
Use '*' for cross-type controls (e.g. a "Notes" section that applies to anything). Use a specific
type for property controls that only make sense for that item type.
De-duplication
The registry de-duplicates by (itemType, label) — re-registering with the same pair is a no-op.
This means two plugins both registering an image section labeled "Effects" will collide; pick
distinct labels.
The component contract
The component receives one prop: { itemId: string }. Use it to look the item up and mutate.
'use client'
import { Label, Slider, useStudioStore } from '@studio-dev/vsdk'
export default function OpacitySection({ itemId }: { itemId: string }) {
const item = useStudioStore((s) => s.timeline.items[itemId])
const updateItemProperties = useStudioStore((s) => s.updateItemProperties)
if (!item) return null
return (
<div className="flex flex-col gap-2 p-2">
<Label>Opacity</Label>
<Slider
min={0}
max={1}
step={0.01}
value={[item.transform.opacity]}
onValueChange={([v]) => updateItemProperties(itemId, { transform: { opacity: v } })}
/>
</div>
)
}Defensive lookups
The item can disappear between the selection event and the next render (someone deleted it, undo happened, etc.). Always guard:
if (!item) return nullUse updateItemProperties for almost everything
The canonical mutation for inspector controls is updateItemProperties(itemId, patch). It accepts a
shallow patch on transform, style, filters, zIndex, locked, disabled, muted,
startFrame, durationFrames — the SDK shallow-merges the patch in and registers an undoable
command.
// Transform — nested partial
updateItemProperties(itemId, { transform: { opacity: 0.5 } })
// Style — accepts an arbitrary object (different per item type)
updateItemProperties(itemId, { style: { ...item.style, brightness: 1.2 } })
// Flags
updateItemProperties(itemId, { locked: !item.locked })If you need to mutate something updateItemProperties doesn't cover (e.g. masks, animation keyframes)
use the dedicated actions (addMask, setKeyframe, …) — they all push commands too.
Debounce continuous controls
Sliders fire onValueChange on every pointer move. Each call pushes a command. The undo stack
quickly fills with 100 "Change opacity" entries that the user can't usefully undo through.
Two strategies:
Throttle pushes during drag, commit on release
import { useRef } from 'react'
import { Slider, useStudioStore, useStudioStoreApi } from '@studio-dev/vsdk'
function OpacityControl({ itemId }: { itemId: string }) {
const storeApi = useStudioStoreApi()
const startOpacityRef = useRef<number | null>(null)
const opacity = useStudioStore((s) => s.timeline.items[itemId]?.transform.opacity ?? 1)
return (
<Slider
min={0} max={1} step={0.01}
value={[opacity]}
onValueChange={([v]) => {
const state = storeApi.getState()
if (startOpacityRef.current === null) {
startOpacityRef.current = state.timeline.items[itemId]?.transform.opacity ?? 1
state.startBatch()
}
state.updateItemProperties(itemId, { transform: { opacity: v } })
}}
onValueCommit={() => {
startOpacityRef.current = null
storeApi.getState().endBatch('Change opacity')
}}
/>
)
}startBatch / endBatch collapses every intermediate mutation into one undoable entry. The user
slides smoothly; ⌘Z reverses the whole drag.
Bypass history for "preview" mutations
If you genuinely don't want any of the intermediate values undoable, write to the store directly
and only call updateItemProperties on commit:
storeApi.setState((draft) => {
const item = draft.timeline.items[itemId]
if (item) item.transform.opacity = v
})This is the escape hatch — see Commands.
Inline vs tab — picking the mode
| Pick inline when | Pick tab when |
|---|---|
| The controls fit in 3–5 rows | The section needs its own scroll area |
| They're properties of the item, not a workflow | They drive a workflow (keyframe editor, mask editor) |
| You want them visible alongside the built-in properties | They'd compete for visual attention |
A typical heavy plugin uses one of each — an inline section for quick adjustments, a tab for the full editor.
Querying registered sections
Custom inspector implementations can list sections via useInspectorSections(itemType) (inline) and
useInspectorTabs(itemType) (tab mode). The built-in inspector uses both internally; your custom UI
can follow the same shape.
import { useInspectorSections, useInspectorTabs } from '@studio-dev/vsdk'
function MyInspector({ item }: { item: TrackItem }) {
const inline = useInspectorSections(item.type)
const tabs = useInspectorTabs(item.type)
// render inline.map((s) => <s.component itemId={item.id} />) and tabs as a tabbed UI
}