Use slots when a node or dashboard tile needs Vue content rather than a stock shape or chart. You paint the interior; the engine keeps the geometry. This page shows exact and wildcard node slots, a reactive child component, and a two-view dashboard with live layout, sizing and viewer switches.
In your Vue 3.4+ project, install the binding and its peers:
bashnpm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue
1. Match node types to slots
On GrafloriaFlow, declaring #node-job opts specs with type: 'job' into HTML rendering. The wildcard #node catches already-custom nodes without an exact slot; it does not opt them in. Set custom: true for that path. An explicit custom: false keeps a node in SVG even when an exact slot exists.
The slot context, NodeSlotProps, contains:
node: the liveNodeModel, not a spec copy.data: the node's payload, or{}.engine: theDiagramEnginethat owns behavior.
The following files draw two connected cards. Build uses the exact slot; Deploy uses the wildcard. Type the specs with NodeSpec and EdgeSpec.
2. Keep live content in a child component
Known issue: In-place live-model data changes do not re-invoke node slots. Until this is fixed, put live content in a child component that reads your own reactive source.
The intended model write is node.setData('status', 'passing'). The flow repaints slots on nodes:change, not on an in-place data change. For a card that needs live updates, use a reactive map keyed by node id instead. The mounted child tracks that map independently of canvas repainting.
Create these three files. The Mark Build passing button changes the visible status in the Build card without adding or removing nodes.
tsimport { reactive } from 'vue';
export const jobs = reactive<Record<string, { title: string; status: string }>>({
build: { title: 'Build', status: 'running' },
deploy: { title: 'Deploy', status: 'ready' },
});
vue<script setup lang="ts"> import { jobs } from './job-state'; defineProps<{ nodeId: string }>(); </script> <template> <article class="job-card"> <strong>{{ jobs[nodeId]?.title }}</strong> <p>Status: {{ jobs[nodeId]?.status }}</p> </article> </template> <style scoped> .job-card { height: 100%; box-sizing: border-box; padding: 16px; border: 2px solid #2563eb; border-radius: 10px; background: #eff6ff; color: #172554; font: 14px/1.4 system-ui, sans-serif; } </style>
vue<script setup lang="ts"> import { GrafloriaFlow, type NodeSpec, type EdgeSpec } from '@grafloria/vue'; import JobCard from './JobCard.vue'; import { jobs } from './job-state'; const nodes: NodeSpec[] = [ { id: 'build', type: 'job', position: { x: 60, y: 90 }, size: { width: 220, height: 110 }, data: { owner: 'CI' }, }, { id: 'deploy', type: 'release', custom: true, position: { x: 390, y: 90 }, size: { width: 220, height: 110 }, data: { owner: 'CD' }, }, ]; const edges: EdgeSpec[] = [ { id: 'build-deploy', source: 'build', target: 'deploy' }, ]; function markPassing() { const build = jobs.build; if (build) build.status = 'passing'; } </script> <template> <button @click="markPassing">Mark Build passing</button> <div style="height: 400px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" fit-view> <template #node-job="{ node }"> <JobCard :node-id="node.id" /> </template> <template #node="{ node }"> <JobCard :node-id="node.id" /> </template> </GrafloriaFlow> </div> </template>
The spec's size determines each footprint; height: 100% and box-sizing: border-box make the card fill it. Custom node content lives in light DOM, so the child component's scoped stylesheet applies. For canvas sizing, see Theme a canvas.
Use a slot for components and controls, not to recreate a stock silhouette. For rectangles, terminals or documents, use the spec's shape instead; compare the custom-shaped nodes demo.
3. Render widget slots and switch the live board
On GrafloriaDashboard, declare #widget-<kind> slots instead of React's widgetTypes; see React: custom content for the shared DashboardWidgetSpec payload contract and shipped-painter fallback.
Replace App.vue with the following independent dashboard sample. Overview opens with a shipped KPI painter and a Vue note slot. Operations contains another note. Your buttons supply the tab bar: v-model:active-view asks the wrapper to show the selected view.
Capture the DashboardHandle through @ready in a shallowRef. Update note calls the WidgetHandle's update() method, which replaces the payload and repaints the slot.
vue<script setup lang="ts"> import { ref, shallowRef } from 'vue'; import { GrafloriaDashboard } from '@grafloria/vue'; import type { DashboardHandle, DashboardWidgetSpec } from '@grafloria/element'; const handle = shallowRef<DashboardHandle | null>(null); const tab = ref('overview'); const layout = ref<'grid' | 'split'>('grid'); const sizing = ref<'fit' | 'grow'>('fit'); const viewer = ref(false); const options = { columns: 6, gap: 10 }; const overviewWidgets: DashboardWidgetSpec[] = [ { id: 'revenue', kind: 'kpi', span: 3, rows: 1, data: { label: 'Total revenue', value: '$6.81M', delta: 12.4, spark: [3.9, 4.4, 4.1, 5.2, 5.9, 6.8] }, }, { id: 'summary', kind: 'note', title: 'Release summary', span: 3, rows: 1, data: { text: 'Build is running.' }, }, ]; const operationsWidgets: DashboardWidgetSpec[] = [ { id: 'operations-note', kind: 'note', title: 'Operations', span: 6, rows: 1, data: { text: 'Deployment window opens at 14:00.' }, }, ]; const views = [ { id: 'overview', name: 'Overview', widgets: overviewWidgets }, { id: 'operations', name: 'Operations', widgets: operationsWidgets }, ]; function updateNote() { handle.value?.widget('summary')?.update({ data: { text: 'Build passed. Ready to deploy.' }, }); } </script> <template> <nav aria-label="Dashboard views"> <button v-for="view in views" :key="view.id" :aria-pressed="tab === view.id" @click="tab = view.id"> {{ view.name }} </button> </nav> <div class="controls"> <button @click="layout = 'grid'">Grid</button> <button @click="layout = 'split'">Split</button> <button @click="sizing = 'fit'">Fit</button> <button @click="sizing = 'grow'">Grow</button> <label><input v-model="viewer" type="checkbox" /> Viewer mode</label> <button :disabled="!handle || tab !== 'overview'" @click="updateNote"> Update note </button> </div> <div style="height: 400px"> <GrafloriaDashboard :views="views" :options="options" v-model:active-view="tab" :layout="layout" :sizing="sizing" :static="viewer" @ready="handle = $event"> <template #widget-note="{ widget, data }"> <article class="note"> <strong>{{ widget.title }}</strong> <p>{{ data.text }}</p> </article> </template> </GrafloriaDashboard> </div> </template> <style scoped> .controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; } .note { height: 100%; box-sizing: border-box; padding: 16px; background: #fffbeb; color: #78350f; font: 14px/1.4 system-ui, sans-serif; } </style>
Split changes every view to a splitter tree with percentage dividers; Grid returns them to cells. These switches call the existing handle, not a remount. In grid mode, Fit keeps the board height and squeezes rows; Grow keeps row height and extends the board downward. Split layout always uses fit sizing. Viewer mode disables pointer dragging and resizing and removes handles.
4. Mount a spec without slot content
Use GrafloriaDiagram when you have a complete RenderSpec rather than node or widget slots. This independent sample draws a stock Build node connected to Deploy. The generic host mounts the spec; changing the spec's value replaces the diagram, while rebuilding an equal spec does not.
vue<script setup lang="ts"> import { GrafloriaDiagram, type NodeSpec, type EdgeSpec } from '@grafloria/vue'; import type { RenderSpec } from '@grafloria/element'; const nodes: NodeSpec[] = [ { id: 'build', label: 'Build', position: { x: 60, y: 90 }, size: { width: 180, height: 80 } }, { id: 'deploy', label: 'Deploy', position: { x: 340, y: 90 }, size: { width: 180, height: 80 } }, ]; const edges: EdgeSpec[] = [ { id: 'build-deploy', source: 'build', target: 'deploy' }, ]; const spec: RenderSpec = { nodes, edges }; </script> <template> <div style="height: 400px"> <GrafloriaDiagram :spec="spec" :options="{ fitView: true }" /> </div> </template>
Options and pitfalls
| Option | Type | Default | What it does |
|---|---|---|---|
Node custom | boolean | Exact-slot opt-in when omitted | true permits wildcard rendering; false overrides an exact slot. |
Widget span / rows | number / number | 3 / 1 | Sets column and row spans. Omit x and y to flow in declaration order. |
Dashboard layout | 'grid' | 'split' | Prop unset; kit uses 'grid' | Overrides options at mount; later changes affect all views. |
Dashboard sizing | 'fit' | 'grow' | Prop unset; fluid boards use 'grow', fixed boards use 'fit' | Overrides options at mount and calls setSizing() on changes. |
Dashboard static | boolean | Prop unset | Overrides options at mount and calls setStatic() on changes. |
Dashboard activeView | string | Prop unset | Chooses the visible view; mount emits the initial id through update:activeView. |
The dashboard wrapper owns options.renderWidget and options.onLayoutChange and overwrites both. Use widget slots and @layout-change instead. That event carries { viewId, widgets }, with widget specs containing their current cells.
Unlike a live-model node-data mutation, widget(id)?.update({ data: ... }) explicitly repaints widget content. Use it for payload changes rather than expecting a changed declaration array to refresh an existing dashboard.
The binding cleans up its mounted content and instance on unmount; do not dispose the handle immediately after @ready.
Live demos and related guides
- Dashboard builder: shipped widget painters and multiple views.
- Fluid dashboard: live sizing and layout switches; Vue source.
- Vue: state and composables: controlled flow data and access from child components.
- Build a dashboard: the board's layout and widget operations.
- Save and restore documents: persistence rather than framework spec projections.
Was this page helpful?