React Flowchart — Build Interactive Flowcharts and Diagrams on Canvas with JavaScript
Build interactive flowcharts and connected diagrams directly on an HTML5 Canvas. Drag nodes around and watch the connecting arrows follow in real time — a common pattern for workflow builders, mind maps, org charts, and visual programming tools. Works with vanilla JavaScript, React (via react-konva), and Vue.
Instructions: Drag any node to reposition it. Connections update automatically. Click "Add Node" to add more.
- Vanilla
- React
- Vue
import Konva from 'konva';
var width = window.innerWidth;
var height = window.innerHeight;
var stage = new Konva.Stage({
container: 'container',
width: width,
height: height,
});
var layer = new Konva.Layer();
stage.add(layer);
var container = document.getElementById('container');
var controls = document.createElement('div');
controls.style.cssText = 'margin-bottom: 8px; display: flex; gap: 8px; align-items: center; font: 13px Arial, sans-serif;';
var addBtn = document.createElement('button');
addBtn.textContent = '+ Add Node';
addBtn.style.cssText = 'padding: 6px 14px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 600;';
addBtn.onclick = function() {
var x = 100 + Math.random() * (width - 200);
var y = 80 + Math.random() * (height - 160);
var node = makeNode('New Node', '#2196F3', x, y);
node.moveToTop();
};
controls.appendChild(addBtn);
var hint = document.createElement('span');
hint.textContent = 'Drag nodes to reposition. Arrows follow automatically.';
hint.style.cssText = 'color: #888; font-size: 12px;';
controls.appendChild(hint);
container.parentNode.insertBefore(controls, container);
var nodeList = [];
var arrowList = [];
function makeNode(label, color, x, y) {
var group = new Konva.Group({ x: x, y: y, draggable: true });
var rect = new Konva.Rect({
width: 120, height: 50,
fill: color, cornerRadius: 8,
shadowColor: 'rgba(0,0,0,0.15)', shadowBlur: 6, shadowOffsetY: 2,
offsetX: 60, offsetY: 25,
});
var text = new Konva.Text({
text: label, fontSize: 14, fontFamily: 'Arial',
fill: '#fff', width: 120, height: 50,
align: 'center', verticalAlign: 'middle',
offsetX: 60, offsetY: 25,
});
group.add(rect);
group.add(text);
layer.add(group);
var entry = { group: group };
nodeList.push(entry);
group.on('dragmove', updateArrows);
return group;
}
function connect(from, to) {
var arrow = new Konva.Arrow({
points: [from.x(), from.y(), to.x(), to.y()],
pointerLength: 10, pointerWidth: 8,
fill: '#555', stroke: '#555', strokeWidth: 2,
listening: false,
});
layer.add(arrow);
arrow.moveToBottom();
arrowList.push({ shape: arrow, from: from, to: to });
}
function updateArrows() {
for (var i = 0; i < arrowList.length; i++) {
var a = arrowList[i];
a.shape.points([a.from.x(), a.from.y(), a.to.x(), a.to.y()]);
}
layer.batchDraw();
}
var start = makeNode('Start', '#4CAF50', 100, 120);
var procA = makeNode('Process A', '#2196F3', 300, 80);
var decide = makeNode('Decision', '#FF9800', 500, 120);
var procB = makeNode('Process B', '#2196F3', 300, 260);
var end = makeNode('End', '#f44336', 500, 260);
connect(start, procA);
connect(procA, decide);
connect(decide, procB);
connect(decide, end);
connect(procB, end);
import React, { useState, useCallback } from 'react';
import { Stage, Layer, Group, Rect, Text, Arrow } from 'react-konva';
var INITIAL_NODES = [
{ id: 'start', label: 'Start', color: '#4CAF50', x: 100, y: 120 },
{ id: 'procA', label: 'Process A', color: '#2196F3', x: 300, y: 80 },
{ id: 'decide', label: 'Decision', color: '#FF9800', x: 500, y: 120 },
{ id: 'procB', label: 'Process B', color: '#2196F3', x: 300, y: 260 },
{ id: 'end', label: 'End', color: '#f44336', x: 500, y: 260 },
];
var CONNECTIONS = [
{ from: 'start', to: 'procA' },
{ from: 'procA', to: 'decide' },
{ from: 'decide', to: 'procB' },
{ from: 'decide', to: 'end' },
{ from: 'procB', to: 'end' },
];
function FlowNode({ id, label, color, x, y, onDragMove }) {
return (
<Group
x={x}
y={y}
draggable
onDragMove={function (e) {
onDragMove(id, e.target.x(), e.target.y());
}}
>
<Rect
width={120}
height={50}
fill={color}
cornerRadius={8}
shadowColor="rgba(0,0,0,0.15)"
shadowBlur={6}
shadowOffsetY={2}
offsetX={60}
offsetY={25}
/>
<Text
text={label}
fontSize={14}
fontFamily="Arial"
fill="#fff"
width={120}
height={50}
align="center"
verticalAlign="middle"
offsetX={60}
offsetY={25}
/>
</Group>
);
}
var App = function () {
var [nodes, setNodes] = useState(INITIAL_NODES);
var [connections, setConnections] = useState(CONNECTIONS);
var [counter, setCounter] = useState(1);
var handleDragMove = useCallback(function (id, x, y) {
setNodes(function (prev) {
return prev.map(function (n) {
return n.id === id ? Object.assign({}, n, { x: x, y: y }) : n;
});
});
}, []);
var nodeMap = {};
nodes.forEach(function (n) {
nodeMap[n.id] = n;
});
var addNode = function () {
var newId = 'new_' + counter;
setCounter(counter + 1);
setNodes(function (prev) {
return prev.concat({
id: newId,
label: 'New Node',
color: '#2196F3',
x: 100 + Math.random() * 400,
y: 80 + Math.random() * 200,
});
});
};
return (
<div>
<div style={{ marginBottom: 8, display: 'flex', gap: 8, alignItems: 'center', font: '13px Arial, sans-serif' }}>
<button
onClick={addNode}
style={{ padding: '6px 14px', background: '#2196F3', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 13, fontWeight: 600 }}
>
+ Add Node
</button>
<span style={{ color: '#888', fontSize: 12 }}>Drag nodes to reposition. Arrows follow automatically.</span>
</div>
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
{connections.map(function (c, i) {
var fromNode = nodeMap[c.from];
var toNode = nodeMap[c.to];
if (!fromNode || !toNode) return null;
return (
<Arrow
key={'arrow-' + i}
points={[fromNode.x, fromNode.y, toNode.x, toNode.y]}
pointerLength={10}
pointerWidth={8}
fill="#555"
stroke="#555"
strokeWidth={2}
listening={false}
/>
);
})}
{nodes.map(function (n) {
return (
<FlowNode
key={n.id}
id={n.id}
label={n.label}
color={n.color}
x={n.x}
y={n.y}
onDragMove={handleDragMove}
/>
);
})}
</Layer>
</Stage>
</div>
);
};
export default App;
<template>
<div>
<div style="margin-bottom: 8px; display: flex; gap: 8px; align-items: center; font: 13px Arial, sans-serif;">
<button
@click="addNode"
style="padding: 6px 14px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 600;"
>
+ Add Node
</button>
<span style="color: #888; font-size: 12px;">Drag nodes to reposition. Arrows follow automatically.</span>
</div>
<v-stage :config="stageConfig">
<v-layer>
<v-arrow
v-for="(c, i) in arrowConfigs"
:key="'arrow-' + i"
:config="c"
/>
<v-group
v-for="n in nodes"
:key="n.id"
:config="{ x: n.x, y: n.y, draggable: true }"
@dragmove="handleDragMove(n.id, $event)"
>
<v-rect :config="{
width: 120, height: 50,
fill: n.color, cornerRadius: 8,
shadowColor: 'rgba(0,0,0,0.15)', shadowBlur: 6, shadowOffsetY: 2,
offsetX: 60, offsetY: 25,
}" />
<v-text :config="{
text: n.label, fontSize: 14, fontFamily: 'Arial',
fill: '#fff', width: 120, height: 50,
align: 'center', verticalAlign: 'middle',
offsetX: 60, offsetY: 25,
}" />
</v-group>
</v-layer>
</v-stage>
</div>
</template>
<script>
import { ref, computed } from 'vue';
export default {
setup() {
var stageConfig = {
width: window.innerWidth,
height: window.innerHeight,
};
var counter = ref(1);
var nodes = ref([
{ id: 'start', label: 'Start', color: '#4CAF50', x: 100, y: 120 },
{ id: 'procA', label: 'Process A', color: '#2196F3', x: 300, y: 80 },
{ id: 'decide', label: 'Decision', color: '#FF9800', x: 500, y: 120 },
{ id: 'procB', label: 'Process B', color: '#2196F3', x: 300, y: 260 },
{ id: 'end', label: 'End', color: '#f44336', x: 500, y: 260 },
]);
var connections = ref([
{ from: 'start', to: 'procA' },
{ from: 'procA', to: 'decide' },
{ from: 'decide', to: 'procB' },
{ from: 'decide', to: 'end' },
{ from: 'procB', to: 'end' },
]);
var arrowConfigs = computed(function () {
var nodeMap = {};
nodes.value.forEach(function (n) {
nodeMap[n.id] = n;
});
return connections.value.map(function (c) {
var fromNode = nodeMap[c.from];
var toNode = nodeMap[c.to];
if (!fromNode || !toNode) return null;
return {
points: [fromNode.x, fromNode.y, toNode.x, toNode.y],
pointerLength: 10,
pointerWidth: 8,
fill: '#555',
stroke: '#555',
strokeWidth: 2,
listening: false,
};
}).filter(Boolean);
});
function handleDragMove(id, e) {
var target = e.target;
var list = nodes.value;
for (var i = 0; i < list.length; i++) {
if (list[i].id === id) {
list[i].x = target.x();
list[i].y = target.y();
break;
}
}
}
function addNode() {
var newId = 'new_' + counter.value;
counter.value = counter.value + 1;
nodes.value.push({
id: newId,
label: 'New Node',
color: '#2196F3',
x: 100 + Math.random() * 400,
y: 80 + Math.random() * 200,
});
}
return {
stageConfig: stageConfig,
nodes: nodes,
connections: connections,
arrowConfigs: arrowConfigs,
handleDragMove: handleDragMove,
addNode: addNode,
};
},
};
</script>
Designing the graph data
The demo above keeps every node in one place and lets each connection store only two node IDs. That normalization matters more than it looks: never copy a node's position into its edges, or every move leaves stale duplicates behind. Read the current node positions when you draw instead.
For a small graph, saving a position on every drag-move event and recalculating the arrows is clear and fast enough. For a large one, move the dragged node directly during the gesture and commit a single document change when the drag ends — one history entry per action, not one per frame.
This example joins the horizontal sides of two rectangles, which reads well for a left-to-right flow. Real editors need explicit ports: store the port ID on the edge and derive its absolute position from the node that owns it. Route orthogonal connectors once positions settle — recalculating a route on every frame is expensive.
If you add pan and zoom, convert pointer positions through the inverse stage transform before any hit or drop test, and keep graph coordinates independent of the camera. See zoom relative to pointer.
For large graphs, draw only visible nodes, use a spatial index for viewport and
selection queries, keep connectors below the node shapes, and set listening: false
on connectors that never receive pointer input. See
performance tips.
Ports, edge selection, cycle rules, automatic layout, history, and persistence are all graph rules. Define each one in the data model before you build its interface.