Learn / Custom nodes in Vue
Custom nodes in Vue
A custom node is a named slot. Declaring #node-<type> is the whole opt-in — no flag, no registry call — and what renders inside is real Vue: components, event handlers, your stylesheet. This page is the full mechanics: how the opt-in works, what the slot receives, when it repaints, and how to reach the engine from inside a node.
The mechanic: a slot per node type
Give a node a type, declare a slot named after it, and the node's body
becomes your template. It still hit-tests, drags, connects and exports like any other
node — the engine keeps the geometry; you paint the interior.
<script setup>
import { GrafloriaFlow } from '@grafloria/vue';
const nodes = [
{ id: 'a', type: 'card', position: { x: 80, y: 90 }, size: { width: 230, height: 110 },
data: { title: 'Build', owner: 'CI', status: 'passing' } },
{ id: 'b', type: 'card', position: { x: 430, y: 90 }, size: { width: 230, height: 110 },
data: { title: 'Deploy', owner: 'CD', status: 'ready' } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
</script>
<template>
<div style="height: 100vh">
<GrafloriaFlow :default-nodes="nodes" :default-edges="edges">
<template #node-card="{ data }">
<div class="card">
<div class="title">{{ data.title }}</div>
<div class="owner">owner: {{ data.owner }}</div>
<span class="badge">{{ data.status }}</span>
</div>
</template>
</GrafloriaFlow>
</div>
</template>
Live: slot-defined custom nodes →
The opt-in rule, quoted from the wrapper source, because the details matter:
/** * Declaring `#node-<type>` IS the opt-in (the same DX as the Angular * wrapper): specs whose type has an exact slot are flagged `custom` * automatically. Explicit `custom` always wins; the wildcard `#node` slot * renders already-custom nodes but does not flag anything itself. */
Three consequences worth reading twice:
- A node whose
typehas an exact slot renders through it — nothing else to write. #node(no type) is the wildcard: it catches custom nodes that have no exact slot, but it never opts a node in by itself. To route a node through the wildcard, setcustom: trueon its spec.- An explicit
customon the spec wins in both directions —custom: falsekeeps a node in SVG even when a matching slot exists.
What the slot receives
Slot context is { node, data, engine }:
node— the liveNodeModel, not a copy. Its setters (setMetadata,setSize,setPosition) write straight into the model.data— the node'sdatapayload ({}when the spec declared none), passed through untouched.engine— theDiagramEngine(the same objectinstance.getEngine()returns): commands, undo/redo, layout, interaction config.
Reactivity, and how repaints actually work
Slot content is mounted with Vue's low-level render() into the host
element the engine provides in its HTML layer. That makes it real Vue — components
mount with their own state and lifecycle, and event handlers are real DOM listeners.
But the repaint schedule is the engine's, not Vue's, so it pays to know when
the wrapper re-invokes your slot:
- On mount — once per custom node, when the engine creates its host.
- On every
nodes:change— which the instance emits on node add and remove only. In-place changes (node:changed) repaint the SVG canvas but do not re-run slots.
node.data — or updating a
controlled v-model:nodes array in place — does not repaint slot
content, because no node was added or removed. A plain interpolation of page-level
state inside a slot refreshes only when a repaint happens to run. For content that
must stay live, put a component in the slot and let it read from your own
reactive source (a store, a reactive map keyed by node.id): a mounted
component re-renders itself, on its own schedule, regardless of the canvas.<template #node-metric="{ node }">
<!-- MetricCard subscribes to your store by id; it stays live on its own -->
<MetricCard :metric-id="node.id" style="height: 100%" />
</template>
Styling and sizing
The wrapper renders your slot inside a width:100%; height:100% host,
and the host is sized to the node's box — so the one rule is: give your slot root
height: 100% (and box-sizing: border-box if it has padding),
or the card will not fill the node it lives in. The node's footprint itself comes from
the spec's size, not from your CSS.
Custom nodes render in light DOM, so your app stylesheet — including
<style scoped> in the SFC that declares the slot — cascades in
directly. No stylesheet import, no shadow-DOM piercing.
width:100%; height:100%, so the element you
mount <GrafloriaFlow> into must have a real height.Ports on custom nodes
Ports belong to the spec, not to the painter — how the body renders and
where edges attach are orthogonal. A custom node you declare no ports for keeps the
default four (one per side, deterministic ids <nodeId>__<side>);
declare ports to replace them:
const nodes = [
{ id: 'src', type: 'card', position: { x: 80, y: 90 }, size: { width: 230, height: 110 },
data: { title: 'Extract' },
ports: [
{ id: 'out', side: 'right', type: 'output', dataType: 'number' },
{ id: 'ctl', side: 'top', type: 'input' },
] },
];
<GrafloriaFlow :default-nodes="nodes" :interaction="{ portVisibility: 'always' }">
<template #node-card="{ data }"> ... </template>
</GrafloriaFlow>
Typed ports, connection validators and port gating all work unchanged on custom
nodes — see Ports & validation. One caveat
carries over: connection validators are process-global, so dispose them in
onBeforeUnmount.
Reaching the model and engine from inside a node
Because the slot gets the live NodeModel and the engine, a node can
carry its own controls. Deletion is one call — it runs through the command layer, so
it is a single undoable step, and the removal fires nodes:change, which
repaints the remaining slots and (in controlled mode) emits the updated specs:
<template #node-card="{ node, data, engine }">
<div class="card">
<strong>{{ data.title }}</strong>
<button class="close" @click="engine.removeNode(node.id)">✕</button>
</div>
</template>
instance.getEngine().undo(). There is no instance.undo() —
reaching for one fails silently in an ?. chain, which makes it a
long-lived bug rather than a loud one.Live: undo/redo from your own buttons →
Common mistakes
- No height on the wrapper. The canvas fills its parent; an unsized parent renders nothing.
- No
height: 100%on the slot root. The card floats at the top of an invisible node box. - Expecting
#nodeto opt nodes in. The wildcard only renders nodes that are already custom — addcustom: trueto the spec. - Expecting a data mutation to repaint the slot. Slots repaint on add/remove; live content belongs in a component with its own reactive source.
instance.undo(). It does not exist; go throughgetEngine().- Doing shape work in a slot that the spec already does. For a styled
rectangle, terminal or document shape,
shape: { type, fill, stroke }on the spec is cheaper than a custom node — see the shape-based demo for the contrast.
Where next
- State & data flow in Vue — controlled vs uncontrolled nodes, the emit rules, saving and loading.
- Dashboards in Vue — the same slot idiom,
#widget-<kind>, on the dashboard kit. - The model — what a
NodeModelreally is under the slot. - Ports & validation — the port vocabulary your custom nodes inherit.