Blog / Tutorials
Build a workflow editor in Vue 3 — the whole thing
Vue gets fewer diagram tutorials than React, and the ones that exist usually stop at "nodes appear on screen". This one goes where real workflow editors go: custom node cards, ports with connection rules, ⌘Z, auto-layout, and a save format — in Vue idiom the whole way. Everything below is running code; it's the same API our Vue demo gallery exercises in CI.
1 — A canvas with taste
<script setup>
import { GrafloriaFlow } from '@grafloria/vue';
const nodes = [
{ id: 'trigger', type: 'step', position: { x: 60, y: 120 },
size: { width: 220, height: 90 }, data: { title: 'New signup', kind: 'Trigger' } },
{ id: 'email', type: 'step', position: { x: 380, y: 120 },
size: { width: 220, height: 90 }, data: { title: 'Send welcome email', kind: 'Action' } },
];
const edges = [{ id: 'e1', source: 'trigger', target: 'email' }];
</script>
<template>
<div style="height: 100vh">
<GrafloriaFlow :default-nodes="nodes" :default-edges="edges" :plugins="true">
<template #node-step="{ data }">
<div class="step">
<span class="kind">{{ data.kind }}</span>
<div class="title">{{ data.title }}</div>
</div>
</template>
</GrafloriaFlow>
</div>
</template>
<style scoped>
.step { height: 100%; background: #fff; border: 1.5px solid #94A5F0; border-radius: 12px;
padding: 10px 14px; box-sizing: border-box; }
.kind { font-size: 11px; font-weight: 600; color: #3B52D9; text-transform: uppercase; }
.title { font-weight: 700; }
</style>
The #node-step slot renders every node of type step —
declaring the slot is the whole custom-node opt-in. :plugins="true"
mounts the minimap, zoom controls and dotted background (lazy-loaded). Give the
wrapper a real height: the canvas fills its container, and 100% of zero is a blank
page.
2 — Ports and the rules of connection
A workflow editor without connection rules teaches users to build broken flows. Ports carry direction; validators carry your domain:
import { registerConnectionValidator, clearConnectionValidators } from '@grafloria/element';
import { onBeforeUnmount } from 'vue';
const nodes = [
{ id: 'trigger', type: 'step', position: { x: 60, y: 120 }, size: { width: 220, height: 90 },
data: { title: 'New signup', kind: 'Trigger' },
ports: [{ id: 't-out', side: 'right', type: 'output' }] },
{ id: 'email', type: 'step', position: { x: 380, y: 120 }, size: { width: 220, height: 90 },
data: { title: 'Send welcome email', kind: 'Action' },
ports: [{ id: 'e-in', side: 'left', type: 'input' },
{ id: 'e-out', side: 'right', type: 'output' }] },
];
let dispose;
function onInit(instance) {
dispose = registerConnectionValidator(({ sourceNode, targetNode }) => {
if (targetNode?.getData?.('kind') === 'Trigger') return 'a Trigger has no inputs';
return true;
});
instance.getEngine().setInteractionConfig({ portVisibility: 'always' });
}
onBeforeUnmount(() => { dispose?.(); clearConnectionValidators(); });
Wire it with @init="onInit". An input port refuses to
start a wire, the validator vetoes with a reason your user sees, and — the part you
never have to build — ⌘Z already works: every gesture is one command on the
engine's history.
3 — Layout and persistence
// stop hand-placing steps: one prop
// <GrafloriaFlow ... layout="dagre" /> (or "elk" for gnarlier graphs)
// save — the whole diagram is one document:
import { DiagramSerializer } from '@grafloria/element';
const json = JSON.stringify(new DiagramSerializer().serialize(instance.getModel()));
// text for humans: the same flow as valid Mermaid, positions preserved
const text = instance.exportText();
Where to go from here
The 10-minute Vue tutorial covers the same ground interactively; the Vue deep guides go into slots, state and dashboards; and every demo in the gallery exists as a real Vue SFC with source shown. If you're coming from Vue Flow, the sourced comparison shows exactly where the boxes differ.