Mind Map App
In this tutorial we build a mind-map app: an editable root node you can branch from by dragging onto the pane, where each new node becomes a child of the one you dragged from. We’ll start from an empty canvas and build up to the finished app.
Getting started
Start with a bare <VueFlow> plus Controls and a
Panel for the title:
<script setup lang="ts">
import { Controls, Panel, VueFlow } from '@xyflow/vue';
</script>
<template>
<VueFlow>
<Controls :show-interactive="false" />
<Panel position="top-left">Vue Flow Mind Map</Panel>
</VueFlow>
</template>A store for nodes and edges
The React version of this tutorial adds Zustand here. Vue Flow already ships a store, so instead we
call setupVueFlow() and bind nodes/edges with
v-model — Vue Flow applies drags, connections, and removals for us. We also set node-origin to
[0.5, 0.5] so a node’s position refers to its center (which keeps the map tidy as it grows), and
style the connection line to match the custom edge we’ll add next.
<script setup lang="ts">
import type { Edge, Node } from '@xyflow/vue';
import { ConnectionLineType, Controls, Panel, VueFlow } from '@xyflow/vue';
import { shallowRef } from 'vue';
const nodes = shallowRef<Node[]>([
{ id: 'root', data: { label: 'Vue Flow Mind Map' }, position: { x: 0, y: 0 } },
]);
const edges = shallowRef<Edge[]>([]);
</script>
<template>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:node-origin="[0.5, 0.5]"
:connection-line-options="{ type: ConnectionLineType.Straight, style: { stroke: '#f6ad55', strokeWidth: 3 } }"
fit-view
>
<Controls :show-interactive="false" />
<Panel position="top-left">Vue Flow Mind Map</Panel>
</VueFlow>
</template>Custom nodes and edges
A mind-map node is just an editable label between two handles. Register it with a #node-mindmap
slot, and give it a matching custom edge — a straight line via
getStraightPath. Every edge gets the mindmap type
through default-edge-options.
<script setup lang="ts">
import type { NodeProps } from '@xyflow/vue';
import { Handle, Position } from '@xyflow/vue';
defineProps<NodeProps>();
</script>
<template>
<input :value="data.label">
<Handle type="target" :position="Position.Top" />
<Handle type="source" :position="Position.Bottom" />
</template><script setup lang="ts">
import type { EdgeProps } from '@xyflow/vue';
import { BaseEdge, getStraightPath } from '@xyflow/vue';
import { computed } from 'vue';
const props = defineProps<EdgeProps>();
const path = computed(() => getStraightPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
}));
</script>
<template>
<BaseEdge :id="id" :path="path[0]" />
</template>New nodes
When you drag from a node and drop on the pane, @connect-end fires with a connectionState. If the
connection wasn’t dropped on a handle (!connectionState.isValid), we create a child of the node the
drag started from and wire an edge to it. Giving the child a parentId positions it relative to its
parent — so we subtract the parent’s absolute position (from
getInternalNode) from the drop point.
const { screenToFlowPosition, addNodes, addEdges, getInternalNode } = setupVueFlow();
let count = 0;
function onConnectEnd({ event, connectionState }: ConnectEndEvent) {
if (connectionState.isValid) {
return;
}
const parent = connectionState.fromNode;
const internal = parent && getInternalNode(parent.id);
if (!parent || !internal) {
return;
}
const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event;
const pane = screenToFlowPosition({ x: clientX, y: clientY });
const { x, y } = internal.internals.positionAbsolute;
const id = `node-${(count += 1)}`;
addNodes([{
id,
type: 'mindmap',
data: { label: 'New Node' },
position: { x: pane.x - x, y: pane.y - y },
parentId: parent.id,
}]);
addEdges([{ id: `e-${parent.id}-${id}`, source: parent.id, target: id }]);
}Keeping data in sync
The label is an <input> — editing it should update the graph, so the node writes changes straight
back with updateNodeData:
<script setup lang="ts">
const { updateNodeData } = useVueFlow();
function onInput(event: Event) {
updateNodeData(props.id, { label: (event.target as HTMLInputElement).value });
}
</script>
<template>
<input :value="data.label" @input="onInput">
...
</template>Simpler UX and nicer styling
Dragging from a small handle is fiddly, so we make the whole node the source handle — a much bigger target to start a connection from. Style the source handle to fill the node and hide the target handle (Vue Flow still needs it to connect):
.vue-flow__handle.source {
top: 0;
left: 0;
transform: none;
background: #f6ad55;
height: 100%;
width: 100%;
border-radius: 2px;
border: none;
}
.vue-flow__handle.target {
top: 50%;
pointer-events: none;
opacity: 0;
}Now the handle covers the input, so we add a small drag handle to move the node and toggle
pointer-events so the input is only editable once focused. Set dragHandle: '.dragHandle' on new
nodes so only that grip moves them.
To grow the input with its text and focus a freshly created node, add a template ref to the input,
set its width in a watchEffect on data.label, and call inputRef.value?.focus() from onMounted.
Final thoughts
That’s a working mind map. Seed it with a starter map by giving the initial nodes parentIds and
edges, and you get the finished app:
From here you could add nodes by clicking the pane, or persist the graph with save and restore.