Building flow graphs
Flow is a headless node-and-edge canvas: panning, zooming, dragging, connecting, selection and virtualization are handled for you; every pixel of a node is yours. This guide covers the contracts that are easy to miss: sizing, custom nodes, drag opt-out, events and the imperative API.
A minimal graph
The pane fills its nearest sized ancestor — the graph lives in absolutely-positioned layers, so the host element must have a real height. fit-view-on-mount frames the graph once nodes are measured; it is skipped when you control the viewport yourself (v-model:viewport / defaultViewport).
<script setup lang="ts">
import { FlowBackground, FlowControls, FlowRoot } from '@robonen/primitives';
import type { FlowEdge, FlowNode } from '@robonen/primitives';
const nodes: FlowNode[] = [
{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'Start' } },
{ id: 'b', position: { x: 260, y: 120 }, data: { label: 'Finish' } },
];
const edges: FlowEdge[] = [
{ id: 'a-b', source: 'a', target: 'b', label: 'then' },
];
</script>
<template>
<!-- The pane fills this element — give it a real height. -->
<div style="height: 480px">
<FlowRoot :default-nodes="nodes" :default-edges="edges" fit-view-on-mount>
<template #node-default="{ node }">
<div class="card">{{ node.data.label }}</div>
</template>
<FlowBackground />
<FlowControls />
</FlowRoot>
</div>
</template>Custom nodes
Nodes render through a component map (nodeTypes, keyed by node.type) or a #node-<type> scoped slot. The slot receives the internal node (node.data is yours) and its selected state. Place FlowHandles anywhere inside — give repeated same-side handles their own anchors, since handles of one type default to the side's midpoint and would overlap.
<!-- Register per-type renderers via nodeTypes (module-level map)… -->
<FlowRoot :node-types="{ scene: SceneNode }" … />
<!-- …or inline via a #node-<type> scoped slot: -->
<FlowRoot :default-nodes="nodes">
<template #node-scene="{ node, selected }">
<article :data-selected="selected" class="scene">
<h4>{{ node.data.title }}</h4>
<!-- One SOURCE handle per row: anchor it to the row, not the side's
midpoint — same-position handles of one type otherwise overlap. -->
<div v-for="option in node.data.options" :key="option.id" class="row">
{{ option.label }}
<FlowHandle
:id="'opt:' + option.id"
type="source"
position="right"
class="row-port"
/>
</div>
</article>
</template>
</FlowRoot>Interactive content and .nodrag
The drag layer owns pointerdown on the node. Native form controls (input, textarea, select, button), [contenteditable] elements and handles are excluded automatically; any other interactive element opts out of dragging with the .nodrag class.
<!-- Form controls inside a node already win over dragging:
input, textarea, select, button, [contenteditable], [data-handleid]
start no drag. Everything else opts out with the .nodrag class: -->
<template #node-scene="{ node }">
<div class="scene">
<button @click="open(node.id)">Edit</button> <!-- just works -->
<div class="nodrag">
<MyColorWheel /> <!-- opted out -->
</div>
</div>
</template>Click, double-click, drag
The drag layer distinguishes a settled click from a drag, so @node-click never fires after a real move, and @node-double-click pairs two settled clicks — double-click on a node does not zoom the canvas. Positions are persisted from @node-drag-stop, which reports every node that moved.
<FlowRoot
:default-nodes="nodes"
@node-click="(id) => select(id)"
@node-double-click="(id) => openEditor(id)"
@node-drag-stop="(ids) => persistPositions(ids)"
@pane-click="clearInspector()"
@edge-click="(id) => selectEdge(id)"
/>The instance API
FlowRoot exposes its whole imperative surface through the template ref — fitView, zooming, viewport get/set, coordinate conversion (screenToFlowPosition / flowToScreenPosition), node/edge lookups and selection control. The full list is on the Flow component page under Exposes.
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { FlowRoot } from '@robonen/primitives';
const flow = useTemplateRef('flow');
function frameSelection(ids: string[]) {
flow.value?.fitView({ padding: 0.2, nodes: ids });
}
function addAtCursor(event: MouseEvent) {
const position = flow.value!.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// …push a node at `position`
}
</script>
<template>
<FlowRoot ref="flow" :default-nodes="nodes" fit-view-on-mount />
</template>Edge labels
An edge with a label renders it at the path midpoint as [data-flow-edge-label], haloed with --flow-edge-label-halo (defaults to white) so it stays readable over the wire. For richer labels, take over the edge with edgeTypes or an #edge-<type> slot.