Canvas widgets
Ship a small, live React surface that a user can drop onto an Undra canvas, where the canvas owns the node and you own only its data.
A canvas widget is a renderer keyed by a widgetKind. You register the renderer in your extension’s activate(ctx); Undra’s canvas provides a built-in Insert Widget affordance that creates the node, seeds it from your defaults, and persists it. Your component never touches the canvas document. It reads data, draws, and calls setData(next) to save. This page assumes you have read the extensions overview and the ctx API.
The model: the canvas owns the node, you own the data
This is the one thing to internalize before writing any code.
- You register a renderer, not a node. A renderer is
{ widgetKind, title, component, ... }. It is just a function the canvas calls to paint the inside of a widget node. - The canvas creates the node. A user right-clicks the canvas, opens the Widgets submenu, and picks your widget by its
title. The canvas inserts awidgetnode, stamps it with yourwidgetKind, seeds itsdatafrom yourdefaultData, and sizes it from yourdefaultSize. You write no creation code. - You never mutate the canvas document. Your component receives the node’s persisted
dataand asetDatafunction. CallingsetData(next)is the only way you change anything, and it writesnextback onto that one node through the canvas’s own update path (with undo/redo and persistence handled for you).
You do not get the canvas doc, the node id, or a save API
A widget component is sandboxed to one node’s data on purpose. There is no handle to the canvas document, no node id, and no ctx.workspace call inside the render path. If you reach for those, you are fighting the model. Everything you need to persist goes through setData.
The definition shape
ctx.registerCanvasWidgets([...]) takes an array of contributions. Each one matches FirstPartyCanvasWidgetContribution:
{
widgetKind: string // required. stable unique id for this renderer
title: string // required. shown in the canvas "Widgets" menu
icon?: unknown // optional. a component; see the note below
defaultData?: Record<string, unknown> // optional. seeds a new node's data
defaultSize?: { width: number; height: number } // optional. new node size in px
component: (props: CanvasWidgetProps) => unknown // required. your renderer
}Field by field:
widgetKindis the stable key. The canvas stamps it on every node your widget creates and looks the renderer up by it at paint time. Namespace it so two extensions never collide, for examplecommunity.example.counter. Registration is last-wins perwidgetKind: if two extensions register the same kind, the later one takes over.titleis the human label in the canvas right-click Widgets submenu. That submenu only appears once at least one widget is registered.iconis optional and typed asunknown(a component). Provide one for forward-compatibility, but note the current canvas Widgets submenu renders a generic grid glyph for every entry, so do not rely on your icon showing there yet.defaultDatais the object copied into a freshly inserted node’sdata. If omitted, new nodes start with{}. The canvas shallow-copies it per node, so each inserted widget gets its own object.defaultSizeis the new node’s size in pixels. If omitted, the canvas falls back to220wide by140tall.componentis the renderer. It returns a React element built withctx.runtime.
The props your component receives
Your component is called with exactly CanvasWidgetProps, nothing more:
type CanvasWidgetProps = {
data: Record<string, unknown> // this node's persisted data (never undefined; {} if empty)
setData: (next: Record<string, unknown>) => void // persist new data onto this node
width: number // current node width in px
height: number // current node height in px
}Key facts, all verified against the runtime:
datais neverundefined. A node with no stored data is handed{}. You still want to read it defensively (cast or default each field) because its shape is whatever you have written over the node’s life.setData(next)replaces the node’s data withnext. It is not a merge. To change one field, spread the old data:setData({ ...data, count: n }). Each call persists immediately and registers an undo step, so do not call it on every animation frame.widthandheighttrack the node’s live size as the user resizes it. Use them to lay out responsively. They are read-only; you cannot resize the node from inside the widget.
Why this is a Record<string, unknown>
The canvas stores widget data generically so any extension can put any JSON-serializable shape in a node. Keep data to plain JSON values (strings, numbers, booleans, arrays, plain objects). Functions, class instances, and DOM nodes will not survive a save and reload.
Capability
Declare the capability in your manifest or the host will refuse to activate the extension:
export const manifest = {
id: 'community.example.counter',
version: '0.1.0',
displayName: 'Counter Widget',
publisher: 'example',
capabilities: ['canvasWidgets.registry'],
}canvasWidgets.registry is the only capability a pure widget needs. If the same extension also registers an item type or commands, add those capabilities (itemTypes.registry, commands.registry) alongside it.
Complete example: a counter widget
A full, runnable single-file extension. It reads a count from data, renders plus and minus buttons, and persists every change through setData. It uses only ctx.runtime React and inline theme styles, so it matches light and dark themes.
export const manifest = {
id: 'community.example.counter',
version: '0.1.0',
displayName: 'Counter Widget',
publisher: 'example',
capabilities: ['canvasWidgets.registry'],
}
export function activate(ctx) {
const { createElement: h } = ctx.runtime
function Counter(props) {
// props is CanvasWidgetProps: { data, setData, width, height }
const count = typeof props.data.count === 'number' ? props.data.count : 0
const set = function (n) {
// setData REPLACES data, so spread to keep any other fields.
props.setData(Object.assign({}, props.data, { count: n }))
}
const wrap = {
width: '100%',
height: '100%',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
padding: '12px',
borderRadius: '12px',
background: 'var(--u-bgPanel, #16191c)',
color: 'var(--u-text0, #f2f4f5)',
border: '1px solid var(--u-border0, rgba(255,255,255,0.12))',
font: 'inherit',
}
const value = { fontSize: '34px', fontWeight: 700, lineHeight: 1 }
const row = { display: 'flex', gap: '8px' }
const btn = {
width: '38px',
height: '34px',
borderRadius: '9px',
cursor: 'pointer',
fontSize: '18px',
lineHeight: 1,
border: '1px solid var(--u-border0, rgba(255,255,255,0.12))',
background: 'var(--u-bg0, #111315)',
color: 'var(--u-text0, #f2f4f5)',
}
const accentBtn = Object.assign({}, btn, {
background: 'var(--u-accent, #5b8def)',
borderColor: 'var(--u-accent, #5b8def)',
color: '#fff',
})
return h('div', { style: wrap },
h('div', { style: { fontSize: '11px', color: 'var(--u-text2, #8c969f)', textTransform: 'uppercase', letterSpacing: '0.06em' } }, 'Counter'),
h('div', { style: value }, String(count)),
h('div', { style: row },
h('button', { style: btn, onClick: function () { set(count - 1) } }, '−'),
h('button', { style: btn, onClick: function () { set(0) } }, '0'),
h('button', { style: accentBtn, onClick: function () { set(count + 1) } }, '+'),
),
)
}
ctx.registerCanvasWidgets([
{
widgetKind: 'community.example.counter',
title: 'Counter',
defaultData: { count: 0 },
defaultSize: { width: 200, height: 150 },
component: Counter,
},
])
return function deactivate() {}
}To try it: save this at <workspace>/.undra/extensions/counter/extension.js, restart Undra, open a canvas, right-click it, and pick Widgets → Counter. The node appears, the buttons work, and the count survives a reload because every change goes through setData.
Reading the node size: a chart placeholder
width and height let a widget lay itself out to fit the node. This placeholder draws a bar proportional to the live node size, then persists a label the user can edit. It shows the responsive pattern without bundling a chart library (for a real chart, bundle one as described under Scaling up).
export const manifest = {
id: 'community.example.chartph',
version: '0.1.0',
displayName: 'Chart Placeholder',
publisher: 'example',
capabilities: ['canvasWidgets.registry'],
}
export function activate(ctx) {
const { createElement: h } = ctx.runtime
function ChartPlaceholder(props) {
const label = typeof props.data.label === 'string' ? props.data.label : 'Untitled'
const pct = typeof props.data.pct === 'number' ? props.data.pct : 60
// Use the live node size to fill the available space.
const barMax = Math.max(0, props.width - 24)
const barWidth = Math.round((barMax * pct) / 100)
return h('div', {
style: {
width: '100%', height: '100%', boxSizing: 'border-box', padding: '12px',
display: 'flex', flexDirection: 'column', gap: '8px',
background: 'var(--u-bgPanel, #16191c)', color: 'var(--u-text0, #f2f4f5)',
border: '1px solid var(--u-border0, rgba(255,255,255,0.12))', borderRadius: '12px',
font: 'inherit',
},
},
h('input', {
value: label,
onChange: function (e) { props.setData(Object.assign({}, props.data, { label: e.target.value })) },
style: {
font: 'inherit', fontSize: '13px', fontWeight: 600, color: 'var(--u-text0, #f2f4f5)',
background: 'transparent', border: 'none', outline: 'none', padding: 0,
},
}),
h('div', { style: { flex: 1, display: 'flex', alignItems: 'flex-end' } },
h('div', {
style: {
width: barWidth + 'px', height: '60%', borderRadius: '6px',
background: 'var(--u-accent, #5b8def)',
transition: 'width 120ms ease',
},
}),
),
h('div', { style: { fontSize: '11px', color: 'var(--u-text2, #8c969f)' } }, pct + '% (' + props.width + '×' + props.height + ')'),
)
}
ctx.registerCanvasWidgets([
{
widgetKind: 'community.example.chartph',
title: 'Chart Placeholder',
defaultData: { label: 'Revenue', pct: 60 },
defaultSize: { width: 280, height: 160 },
component: ChartPlaceholder,
},
])
return function deactivate() {}
}How the user inserts it
You do not build the insertion UI. Once your extension is active:
- The user right-clicks an open canvas.
- A Widgets submenu appears in the context menu (only present because a widget is registered).
- Each registered widget is listed by its
title. Picking one inserts a node at the click point. - The canvas seeds the new node’s
datafrom yourdefaultData(or{}) and its size from yourdefaultSize(or220 x 140), then selects it.
From then on the node lives in the canvas document. Your renderer is called whenever the node paints, with that node’s current data, width, and height.
Late registration is handled
Community extensions activate after the canvas has already painted. The canvas subscribes to the widget registry, so a widget you register after boot still shows up in the Widgets menu and existing nodes of that widgetKind re-render once the renderer lands. If a node references a widgetKind with no registered renderer (for example the extension is disabled), the canvas shows a small “missing widget” placeholder labeled with the kind instead of erroring.
Scaling up: real libraries and TypeScript
The single-file form is right for a counter or a small placeholder. For a real chart widget, write it as normal TypeScript with a charting library and bundle it into one extension.js with esbuild, exactly as the overview’s “Scaling up” section describes. Two rules carry over and matter especially for widgets:
- Use Undra’s React, never your own. Take
createElementand hooks fromctx.runtime. Two copies of React break hooks, and a widget that mounts inside the canvas is the easiest place to hit that bug. - Bundle every other library freely. Charts, parsers, date utilities: inline them. The loader only needs one import-free file.
See Packaging and distribution for the build and the signed community catalog.
Where to go next
- The ctx API for
ctx.runtime,ctx.workspace, and the rest of the activation surface. - Item types if you want a full first-class item with its own editor instead of a canvas node.
- Packaging and distribution to bundle, sign, and publish your widget.