# Vue: slots and widgets

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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#nodeslotprops), contains:

- `node`: the live [`NodeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel#nodemodel), not a spec copy.
- `data`: the node's payload, or `{}`.
- `engine`: the [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine#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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#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 title="job-state.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 title="JobCard.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 title="App.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.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/acf57cabed039ddee9f37da8d1c9a6f9.png)

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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/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](https://grafloria.com/demos/nodes/custom-nodes.html).

## 3. Render widget slots and switch the live board

On [`GrafloriaDashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriadashboard), declare `#widget-<kind>` slots instead of React's `widgetTypes`; see [React: custom content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-custom-content) for the shared [`DashboardWidgetSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-dashboardwidgetspec#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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-dashboardhandle#dashboardhandle) through `@ready` in a `shallowRef`. **Update note** calls the [`WidgetHandle`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-widgethandle#widgethandle)'s `update()` method, which replaces the payload and repaints the slot.

```vue title="App.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.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/af868db3f8a08af9caec5f98b33b960b.png)

**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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriadiagram) when you have a complete [`RenderSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#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 title="App.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](https://grafloria.com/demos/dashboard/dashboard-builder.html): shipped widget painters and multiple views.
- [Fluid dashboard](https://grafloria.com/demos/dashboard/fluid-board.html): live sizing and layout switches; [Vue source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/apps/demos-vue/src/demos/fluid-board.vue).
- [Vue: state and composables](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-state-and-composables): controlled flow data and access from child components.
- [Build a dashboard](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/build-a-dashboard): the board's layout and widget operations.
- [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents): persistence rather than framework spec projections.
