Blog / Tutorials

Build a workflow editor in Vue 3 — the whole thing

August 22, 2026 · tutorial · vue

vue tutorial diagrams

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.