Skip to content
D
Documentation

Vue: slots and widgets

how-to
4 min readUpdated

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:

bash
npm 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 live NodeModel, not a spec copy.
  • data: the node's payload, or {}.
  • engine: the DiagramEngine that 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.

ts
import { 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>
Build and Deploy cards connected by an arrow, with running and ready statuses and the Mark Build passing button.

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>
Overview shows a revenue KPI and Release summary note beneath the view buttons, layout and sizing switches, Viewer mode checkbox and Update note button.

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

OptionTypeDefaultWhat it does
Node custombooleanExact-slot opt-in when omittedtrue permits wildcard rendering; false overrides an exact slot.
Widget span / rowsnumber / number3 / 1Sets 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 staticbooleanProp unsetOverrides options at mount and calls setStatic() on changes.
Dashboard activeViewstringProp unsetChooses 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.

Was this page helpful?