Skip to Content
LearnTutorialsMind Map

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:

App.vue
<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.

App.vue
<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.

MindMapNode.vue
<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>
MindMapEdge.vue
<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.

App.vue
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:

MindMapNode.vue
<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):

index.css
.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.

Last updated on