PlantUML with interactive diagrams — react-flow / Vue Flow / vis.js when PlantUML alone isn't enough
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
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 system — tap / 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 ; 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' , 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 ) => { const classMatch = line.match (/^class\s+(\w+)\s*\{/ ); if (classMatch) { nodes.push ({ id : classMatch[1 ], position : { x : 0 , y : i * 100 }, data : { label : classMatch[1 ] } }); } 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