PlantUML with interactive diagrams — react-flow / Vue Flow / vis.js when PlantUML alone isn't enough

puml.online

PlantUML outputs SVG / PNG / PDF — but no interactivity: can’t drag, can’t highlight, can’t dynamically mutate. That’s “document”, not “tool” — many frontend projects need the latter. This post distils PlantUML’s pairing with mainstream interactive graph libraries.

Where PlantUML sits

PlantUML output is static:

  • ✅ SVG (embed in docs)
  • ✅ PNG (screenshot)
  • ✅ PDF (print)
  • nodes can’t be dragged
  • can’t click-to-highlight
  • can’t edit live

If you need “drag to draw”, “click to expand”, or any dynamic ability — PlantUML alone isn’t enough. You need a frontend graph library.

Today’s mainstream interactive libraries

Library Maintainer Edge type Complexity
react-flow xyflow/React nodes + edges + handles low → med
Vue Flow @vue-flow same (Vue flavour) low → med
vis.js visjs.org timeline / network medium
Cytoscape.js cytoscape.org complex network / graph med → high
D3 D3 Observable data-driven SVG high

react-flow (most common)

Install

1
2
npm install reactflow
# Note: don't use the `react-flow` package (newer version is `reactflow`)

Minimal demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import ReactFlow, {
Background,
Controls,
MiniMap
} from 'reactflow';
import 'reactflow/dist/style.css';

const nodes = [
{ id: '1', position: { x: 100, y: 100 }, data: { label: 'Frontend' } },
{ id: '2', position: { x: 300, y: 200 }, data: { label: 'Backend' } }
];

const edges = [
{ id: 'e1-2', source: '1', target: '2' }
];

function MyDiagram() {
return (
<div style={{ width: '100%', height: '600px' }}>
<ReactFlow nodes={nodes} edges={edges} fitView>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
);
}

Strengths

  • React-native API — fits the React ecosystem.
  • Nodes draggable, editable, highlightable.
  • Handle system — control where edges connect.
  • MiniMap + Controls built-in.
  • Animated edges — paths can animate.

Weaknesses

  • State is in React state; large graphs (>100 nodes) re-render slowly.
  • Node / edge types must be self-defined or use community presets.
  • Complex layouts (e.g. ELK) require external algorithms.

Vue Flow (the Vue flavour of react-flow)

Install

1
npm install @vue-flow/core

Usage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup>
import { VueFlow } from '@vue-flow/core'
import '@vue-flow/core/dist/style.css'

const nodes = [
{ id: '1', position: { x: 100, y: 100 }, data: { label: 'Frontend' } }
]
const edges = [
{ id: 'e1-2', source: '1', target: '2' }
]
</script>

<template>
<div style="width: 100%; height: 600px">
<VueFlow :nodes="nodes" :edges="edges" />
</div>
</template>

Vue Flow’s API is nearly identical to react-flow — same names, same behaviour, just JSX → template. We won’t deep-dive Vue Flow’s internals here.

vis.js (timeline / network)

Install

1
npm install vis-network vis-timeline vis-data

Timeline (PlantUML sequence replacement)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Timeline, DataSet } from 'vis-timeline/standalone';

const items = new DataSet([
{ id: 1, content: 'Design', start: '2026-01-01' },
{ id: 2, content: 'Develop', start: '2026-02-01' },
{ id: 3, content: 'Launch', start: '2026-04-01' }
]);

const options = {
stack: true,
orientation: { axis: 'top', item: 'top' }
};

new Timeline(container, items, options);

Network (PlantUML component replacement)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Network } from 'vis-network/standalone';

const nodes = [
{ id: 1, label: 'Frontend' },
{ id: 2, label: 'Backend' }
];

const edges = [
{ from: 1, to: 2, arrows: 'to' }
];

const data = { nodes: new DataSet(nodes), edges: new DataSet(edges) };

new Network(container, data, {});

Strengths

  • Timeline scenarios are unbeatable — PlantUML has no timeline.
  • Network graphs are dead simple — no state to write.
  • Large graphs (>500 nodes) supported.

Weaknesses

  • Looks “old-school” (2014 project).
  • React / Vue wrappers aren’t as native as react-flow.
  • Documentation scattered.

Cytoscape.js (graph / relationship complexity)

When it fits

  • Knowledge graphs (KG).
  • Bioinformatics networks.
  • Large-graph analysis (>1000 nodes).
  • Need complex selection algorithms.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import cytoscape from 'cytoscape';

const cy = cytoscape({
container: document.getElementById('cy'),
elements: [
{ data: { id: 'a', label: 'Node A' } },
{ data: { id: 'b', label: 'Node B' } },
{ data: { id: 'ab', source: 'a', target: 'b' } }
],
style: [
{ selector: 'node', style: { 'background-color': '#666', label: 'data(label)' } },
{ selector: 'edge', style: { 'width': 3, 'line-color': '#ccc' } }
],
layout: { name: 'cose' }
});

Strengths

  • Complete graph algorithms — cose / fcose / circle / breadthfirst / grid / etc.
  • Selectors — CSS-style node / edge selection.
  • Event systemtap / mouseover / etc.
  • Live style binding — selectors respond to state changes.

Weaknesses

  • Steep learning curve.
  • Not “React-first” — needs a wrapper.

D3 (most freedom, lowest level)

When to pick D3

  • Need full control over rendering.
  • Want non-traditional charts (chord / sankey / force-directed).
  • Already in D3 ecosystem (d3-scale, d3-geo, etc.).

When not to pick D3

  • Just nodes + edges — react-flow is lighter.
  • Team doesn’t want to write “data → SVG” logic — cytoscape / vis.js wins.

Practice: PlantUML → react-flow conversion

Our own project (puml.online) has a PlantUML editor. When we want a “click node, highlight matching region” feature, we need to convert PlantUML to react-flow data.

Parse PlantUML

PlantUML outputs SVG — use jsdom to parse the SVG, extract nodes / edges:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { JSDOM } from 'jsdom';

async function pumlSvgToFlow(svgText) {
const dom = new JSDOM(svgText);
const doc = dom.window.document;

const nodes = [];
const edges = [];

doc.querySelectorAll('.node, g.node, polygon, ellipse').forEach(el => {
const id = el.id; // PlantUML assigns each node a unique ID
const transform = el.getAttribute('transform') || '';
const m = transform.match(/translate\(([\d.-]+),([\d.-]+)\)/);
const position = m ? { x: +m[1], y: +m[2] } : { x: 0, y: 0 };
const label = el.querySelector('text')?.textContent || id;

nodes.push({
id,
type: 'default',
position,
data: { label }
});
});

doc.querySelectorAll('path').forEach(el => {
const d = el.getAttribute('d');
if (d && (d.includes('L') || d.includes('C'))) {
edges.push({
id: el.id || crypto.randomUUID(),
source: 'unknown', // Need PlantUML metadata
target: 'unknown'
});
}
});

return { nodes, edges };
}

In reality, PlantUML SVG doesn’t carry source / target directly — you need PlantUML’s AST output (-pipe -tlint / -checkonly AST output) to do the reverse mapping.

Better approach: PlantUML server returns JSON AST

1
2
curl -X POST "http://localhost:8080/json" \
--data-urlencode "diagram=@startuml ... @enduml"

Returns:

1
2
3
4
5
6
7
8
9
{
"diagram_type": "sequence",
"participants": [
{ "name": "Alice", "type": "actor" }
],
"messages": [
{ "from": "Alice", "to": "Bob", "label": "hello" }
]
}

Caveat: official plantuml-server’s /json route is unstable; many versions don’t implement it. Check your version.

Alternative: hand-write a converter

The most stable route is write your own PlantUML-text → react-flow-data converter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { Edge, Node } from 'reactflow';

export interface ParsedDiagram {
type: 'sequence' | 'class' | 'state' | 'component' | 'deployment';
nodes: Node[];
edges: Edge[];
}

class PUMLParser {
parse(code: string): ParsedDiagram {
if (code.includes('@startuml')) {
return this.parseClass(code);
}
throw new Error('Unsupported type');
}

private parseClass(code: string): ParsedDiagram {
const lines = code.split('\n');
const nodes: Node[] = [];
const edges: Edge[] = [];

lines.forEach((line, i) => {
// class Foo {...}
const classMatch = line.match(/^class\s+(\w+)\s*\{/);
if (classMatch) {
nodes.push({
id: classMatch[1],
position: { x: 0, y: i * 100 }, // simple layout
data: { label: classMatch[1] }
});
}

// relation: A --> B / A --|> B / A *-- B
const relMatch = line.match(/(\w+)\s+(--\|>|--\*|-->|o--|--)\s+(\w+)/);
if (relMatch) {
const [, src, , dst] = relMatch;
edges.push({
id: `${src}-${dst}-${i}`,
source: src,
target: dst
});
}
});

return { type: 'class', nodes, edges };
}
}

Generic, doesn’t depend on PlantUML server. Complex syntax (nested package / generics) coverage ~60% — edit manually when needed.

Practice: interactive PlantUML editor demo

A “write puml, watch react-flow” workflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useState, useEffect } from 'react';
import ReactFlow from 'reactflow';
import { PUMLParser } from './puml-parser';

function InteractiveDiagram() {
const [code, setCode] = useState('@startuml\nclass A\nclass B\nA --> B\n@enduml');
const [diagram, setDiagram] = useState(new PUMLParser().parse(code));

useEffect(() => {
setDiagram(new PUMLParser().parse(code));
}, [code]);

return (
<div style={{ display: 'flex', height: '100vh' }}>
<textarea
value={code}
onChange={e => setCode(e.target.value)}
style={{ width: '40%', fontFamily: 'monospace' }}
/>
<div style={{ flex: 1 }}>
<ReactFlow
nodes={diagram.nodes}
edges={diagram.edges}
fitView
nodesDraggable
onNodeClick={(_, node) => alert(`Clicked: ${node.data.label}`)}
/>
</div>
</div>
);
}

Why this demo is useful:

  • Edit puml text → react-flow auto re-renders.
  • Nodes are draggable (you can “manually tune the layout”).
  • Click a node to trigger business logic.

Decision matrix

Scenario Recommended
“Draw for readers” (blog / docs) PlantUML → SVG
“Drag, edit, click” canvas react-flow / Vue Flow
Timeline / Gantt vis-timeline
Complex KG (>1000 nodes) Cytoscape.js
Sankey / chord / other non-standard D3
“Docs + light interaction” PlantUML + post-processing

PlantUML + react-flow dual-stack practice

“Docs need PlantUML static, tools need react-flow interactive” — same source, two publishes.

1
2
3
4
5
6
7
project/
├── docs/diagrams/ (puml source)
│ ├── auth.puml
│ └── order.puml
└── app/diagrams/ (react-flow data)
├── auth.json (parsed)
└── order.json

scripts/parse-puml.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import fs from 'node:fs';
import path from 'node:path';
import { PUMLParser } from '../parser/puml';

const dir = 'docs/diagrams';
const outDir = 'app/diagrams';
fs.mkdirSync(outDir, { recursive: true });

for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.puml')) continue;
const code = fs.readFileSync(path.join(dir, file), 'utf-8');
const diagram = new PUMLParser().parse(code);
fs.writeFileSync(
path.join(outDir, file.replace('.puml', '.json')),
JSON.stringify(diagram, null, 2)
);
}

CI runs:

1
2
- name: Parse PUML  React Flow
run: node scripts/parse-puml.ts

In summary: when to choose what

  • Static diagrams only (docs, blog, PPT) — PlantUML.
  • Drag, edit canvas — react-flow / Vue Flow.
  • Timeline / Gantt — vis-timeline.
  • Large-graph algorithm analysis (1000+ nodes) — Cytoscape.js.
  • Non-standard charts (sankey etc.) — D3.
  • Both — PlantUML as source of truth + parse to frontend graph data.

Recap

  • PlantUML is a static DSL — not responsible for interaction.
  • react-flow / Vue Flow is the “table” for “drag” and “click”.
  • PlantUML SVG → react-flow conversion is non-trivial. Rely on PlantUML server JSON AST (unstable) or write your own parser.
  • Realistic split: “docs” with PlantUML, “tools” with react-flow, both kept in sync from one source.

Next

  • Title: PlantUML with interactive diagrams — react-flow / Vue Flow / vis.js when PlantUML alone isn't enough
  • Author: puml.online
  • Created at : 2026-07-30 13:01:00
  • Updated at : 2026-08-14 21:34:29
  • Link: https://puml.online/blog/plantuml-react-flow-integration-en/
  • License: This work is licensed under CC BY-NC-SA 4.0.