@houseofbits/vue-nodus
| Install | |
|---|---|
npm install @houseofbits/vue-nodus |
|
| Latest Version: | 0.2.17 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Jul 19, 2026 |
| Links: | GitHub · npm |
@houseofbits/vue-nodus
Features
- Custom nodes — extend
NodusBaseNodeand pair it with your own Vue component; the library handles dragging, connecting, and deleting (Creating custom nodes) - Live data flow — reactive ports propagate values through the graph via
compute(), with batched re-computation in topological order - Undo and redo — drags, deletes, and interactive connections are tracked automatically;
board.history.recordsexposes labeled entries for building a history panel (Undo and redo) - Connection styles — bezier, straight, step, and smooth-step edges, each with a dashed variant, plus custom edge classes and a per-connection type resolver (Connection types)
- Typed ports — connections are validated by port type and direction; inputs accept a single connection by default or many with
isMultiport - Rich selection — single click, shift multi-select, and box/marquee select across both nodes and connections
- Pan & zoom canvas — wheel zoom toward the cursor, right-drag panning, an adaptive dot grid, and a scoped
#backgroundslot for custom backdrops (Interactivity) - Touch support — one-finger pan, pinch-to-zoom, tap-to-connect, and touch-friendly delete markers
- Fully themeable — every visual token is a CSS custom property, settable via a typed
themeprop or plain CSS (Theming) - Serializable — save and restore nodes, connections, and viewport to plain JSON with one call (Serialization)
- Vue 3 native + TypeScript — Composition API throughout, no Canvas hacks, fully typed with shipped
.d.ts
Demo
Installation
npm install @houseofbits/vue-nodus
Requires Vue 3.5+ as a peer dependency.
Quick start
<template>
<div style="position: relative; width: 100%; height: 600px;">
<VGraph :board="board" />
</div>
</template>
<script setup lang="ts">
import { NodusBoard, NodusPort, VGraph } from '@houseofbits/vue-nodus'
import MyNodeComponent from './MyNode.vue'
import MyNode from './MyNode'
const board = new NodusBoard()
// Register your custom node component
board.registerComponent('my-node', MyNodeComponent)
// Create and add a node to the graph
const node = new MyNode()
node.setPosition(100, 100)
board.graph.addNode(node)
</script>
VGraphmust be placed inside a positioned container with an explicit size — it fills 100% of its parent.
Creating custom nodes
A custom node consists of two parts: a model class (extends NodusBaseNode) and a Vue component that renders it.
1. Define the model
// AddNode.ts
import { NodusBaseNode, NodusPort } from '@houseofbits/vue-nodus'
export default class AddNode extends NodusBaseNode {
constructor() {
super(
'add-node', // component ID — must match registerComponent()
[
new NodusPort('number', '#4fc3f7'), // input port: type, color
new NodusPort('number', '#4fc3f7'),
],
[
new NodusPort('number', '#81c784'), // output port
],
{ title: 'Add' }
)
}
compute() {
const a = (this.inputs[0].value as number) ?? 0
const b = (this.inputs[1].value as number) ?? 0
this.outputs[0].value = a + b
}
}
2. Write the Vue component
<!-- AddNode.vue -->
<template>
<VBaseNode :node="props.node" :is-selected="props.isSelected">
<template #content>
<VNodeRow :input-port="props.node.inputs[0]">
<span>A</span>
</VNodeRow>
<VNodeRow :input-port="props.node.inputs[1]">
<span>B</span>
</VNodeRow>
<VNodeRow :output-port="props.node.outputs[0]">
<span>Result</span>
</VNodeRow>
</template>
</VBaseNode>
</template>
<script setup lang="ts">
import { VBaseNode, VNodeRow } from '@houseofbits/vue-nodus'
import AddNode from './AddNode'
const props = defineProps({
node: { type: AddNode, required: true },
isSelected: { type: Boolean, default: false },
})
</script>
3. Register and use
import { NodusBoard } from '@houseofbits/vue-nodus'
import AddNode from './AddNode'
import AddNodeComponent from './AddNode.vue'
const board = new NodusBoard()
board.registerComponent('add-node', AddNodeComponent)
const node = new AddNode()
node.setPosition(200, 150)
board.graph.addNode(node)
Port options
new NodusPort(
type, // string — used for connection type-matching
color?, // CSS color string, default 'white'
isMultiport?, // allow multiple incoming connections, default false
defaultValue?, // initial port value
)
Two ports can only be connected if they share the same type string.
Node settings
The fourth argument to NodusBaseNode accepts optional settings:
new NodusBaseNode('my-node', inputs, outputs, {
title: 'My Node', // shown in the title bar
width: 200, // fixed width in px (null = auto)
height: 150, // fixed height in px (null = auto)
isThinComponent: true, // true = wrap in VBaseNode; false = full custom component
isPortAutoLayoutEnabled: true, // auto-space ports along the node sides
})
Set isThinComponent: false when your component handles its own outer shell (no VBaseNode wrapper).
Theming
All visual tokens are exposed as CSS custom properties. Pass a theme prop to VGraph for a typed API, or override the CSS variables on any ancestor element.
Via the theme prop
<VGraph :board="board" :theme="{
canvasBg: '#1a1a2e',
nodeTitleBg: '#6a0dad',
nodeTitleColor: '#fff',
nodeContentBg: '#2a2a3e',
nodeContentBorderColor: '#3a3a5e',
nodeTitleBorderColor: '#8a4fbf',
nodeTitleBottomBorder: '#4a1a7e',
gridDotColor: 'rgba(180, 120, 255, 0.2)',
}" />
Import the NodusTheme type for typed theme objects:
import type { NodusTheme } from '@houseofbits/vue-nodus'
const darkTheme: Partial<NodusTheme> = {
canvasBg: '#1a1a2e',
nodeTitleBg: '#6a0dad',
}
Via CSS custom properties
Set variables on any ancestor element — they cascade naturally to all components inside that container.
.my-editor {
--nodus-canvas-bg: #1a1a2e;
--nodus-node-title-bg: #6a0dad;
--nodus-node-content-bg: #2a2a3e;
--nodus-port-size: 16px;
}
Available tokens
| Token | Default | Description |
|---|---|---|
--nodus-canvas-bg |
rgb(48, 48, 48) |
Canvas background color |
--nodus-grid-dot-color |
rgba(120, 170, 255, 0.18) |
Grid dot color |
--nodus-grid-dot-size |
1.5px |
Grid dot radius |
--nodus-node-border-radius |
8px |
Node corner radius |
--nodus-node-shadow |
0 6px 15px rgba(0,0,0,0.2) |
Node drop shadow |
--nodus-node-selection-color |
rgba(255,255,255,1) |
Selection outline color |
--nodus-node-selection-width |
4px |
Selection outline width |
--nodus-node-title-bg |
blue gradient | Title bar background (accepts any CSS background value) |
--nodus-node-title-color |
white |
Title bar text color |
--nodus-node-title-border-color |
#82c2ff |
Title bar top/left/right border color |
--nodus-node-title-bottom-border |
#005a9e |
Title bar bottom border color |
--nodus-node-content-bg |
#cfcfcf |
Node content area background |
--nodus-node-content-border-color |
#ffffff |
Node content area border color |
--nodus-port-size |
20px |
Port circle diameter |
--nodus-port-hover-outline-color |
rgba(255,255,255,1) |
Port hover outline color |
--nodus-port-hover-outline-width |
3px |
Port hover outline width |
--nodus-connection-width |
4 |
Connection stroke width (SVG user units) |
--nodus-connection-selection-color |
white |
Selected connection outline color |
--nodus-connection-selection-width |
12 |
Selected connection outline width |
Port and connection colors are set per-instance via NodusPort.color and NodusConnection.color.
Interactivity
| Action | Gesture |
|---|---|
| Move node | Left-click drag on title bar |
| Select node / connection | Left-click |
| Multi-select | Shift + left-click |
| Box select | Left-click drag on empty canvas (Shift + drag extends the selection) |
| Pan canvas | Right-click drag, or one-finger drag on touch |
| Zoom | Scroll wheel, or two-finger pinch on touch |
| Connect ports | Click source port, then click target port (same type, opposite direction) |
| Delete selected | Delete key |
| Delete on touch | Tap the × on a selected node's title bar, or on a selected connection |
| Undo | Ctrl/Cmd + Z |
| Redo | Ctrl/Cmd + Shift + Z, or Ctrl/Cmd + Y |
Connection types
Every connection is drawn by a named connection type. Eight are built in: bezier (the default), straight, step, and smoothstep, each with a -dashed variant (bezier-dashed, etc.).
Choosing a type per connection
Register a resolver to decide which type applies whenever the user connects two ports. Return a registered name, a NodusConnectionType subclass (auto-registered on first use), or undefined to fall back to bezier:
board.registerConnectionTypeResolver((source, target) => {
if (source.type === 'signal') return 'step-dashed'
return undefined // default bezier
})
When restoring a saved graph, each connection keeps the connectionType it was created with.
Custom connection types
Subclass NodusConnectionType, implement buildPath() and getMidpoint(), and register it under a name:
import { NodusConnectionType } from '@houseofbits/vue-nodus'
class ZigZag extends NodusConnectionType {
buildPath(x1, y1, x2, y2, invert) {
const midX = (x1 + x2) / 2
return `M ${x1} ${y1} L ${midX} ${y2} L ${x2} ${y2}`
}
getMidpoint(x1, y1, x2, y2, invert) {
return { x: (x1 + x2) / 2, y: y2 }
}
}
board.registerConnectionType('zigzag', new ZigZag())
board.registerConnectionType('zigzag-dashed', new ZigZag({ dashed: true }))
A connection type can also set an optional render Vue component (receiving ConnectionRenderProps) to fully replace the default edge rendering.
Note: the live preview line shown while dragging out a new connection is always a bezier curve, regardless of the type the resolver will assign.
Serialization
Save the entire graph state (nodes, connections, viewport) to a plain JSON object:
const saved = board.serializer.serialize()
// {
// nodes: { [id]: { ...nodeData, ports: {...} } },
// connections: { [id]: { sourcePortId, targetPortId, color, connectionType } },
// board: { panX, panY, zoom }
// }
localStorage.setItem('graph', JSON.stringify(saved))
To restore, pass the saved data and a factory function that maps a componentId string to a new node instance:
const saved = JSON.parse(localStorage.getItem('graph')!)
board.serializer.deserialize(saved, (componentId) => {
switch (componentId) {
case 'add-node': return new AddNode()
// add cases for every registered node type
default: throw new Error(`Unknown node type: ${componentId}`)
}
})
deserialize clears the existing graph, recreates all nodes via your factory, restores port values and connections, and resets the viewport — all in one call.
Override serialize() / deserialize() on your NodusBaseNode subclass to persist custom properties:
serialize() {
return { label: this.label }
}
deserialize(data: any) {
this.label = data.label ?? ''
}
Undo and redo
Every board ships with an undo/redo history at board.history. The keyboard shortcuts (Ctrl/Cmd + Z to undo, Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y to redo) work out of the box — but restoring a snapshot needs to reconstruct node instances, so you must register a node factory first. It's the same factory shape serializer.deserialize uses, so one function serves both:
const createNode = (componentId: string) => {
switch (componentId) {
case 'add-node': return new AddNode()
default: throw new Error(`Unknown node type: ${componentId}`)
}
}
board.history.setNodeFactory(createNode)
board.serializer.deserialize(saved, createNode)
Calling undo()/redo() without a factory throws a descriptive error.
What gets tracked
Node drags (coalesced into a single entry — a click without movement records nothing), node and connection deletes, and interactive connections are pushed onto the undo stack automatically. Programmatic mutations like graph.addNode() / graph.removeNode() are not — call board.history.snapshot(label) immediately before them if they should be undoable:
board.history.snapshot('Node added')
board.graph.addNode(new AddNode())
The undo stack holds 50 entries by default; a new action clears the redo stack.
Building a history panel
records returns the undoable actions (oldest first) with human-readable labels like "Node Add moved" or "Connected Add and Output" — render it alongside undo() / redo() / canUndo() / canRedo() for a toolbar or dropdown. See VHistoryToolbar.vue in the repository's demo app for a complete example.
For continuous gestures of your own, bracket them with captureSnapshot() / commitGesture(): capture before the gesture starts, commit when it ends, and nothing is recorded if the state didn't change.
API reference
NodusBoard
Top-level orchestrator. Create one instance per editor.
| Member | Type | Description |
|---|---|---|
graph |
NodusGraph |
The node graph |
view |
View |
Interaction and rendering state |
serializer |
NodusSerializer |
Save/restore helpers |
history |
NodusHistory |
Undo/redo stacks (see Undo and redo) |
connectionTypes |
NodusConnectionTypeRegistry |
Named connection types (see Connection types) |
registerComponent(id, component) |
method | Register a Vue component for a node type |
getComponent(id) |
method | Look up a registered component by ID |
registerConnectionType(name, type) |
method | Register a custom NodusConnectionType instance |
getConnectionType(name) |
method | Look up a registered connection type by name |
registerConnectionTypeResolver(resolver) |
method | Decide the connection type for each newly connected port pair |
NodusGraph
Manages nodes and connections. All mutations are reactive.
| Member | Description |
|---|---|
nodes |
Map<string, NodusBaseNode> |
connections |
Map<string, NodusConnection> |
addNode(node) |
Add a node |
removeNode(id) |
Remove a node and its connections |
addConnection(conn) |
Add a connection |
removeConnection(id) |
Remove a connection |
evaluate() |
Re-compute all nodes in topological order |
NodusBaseNode
Extend this class to create custom node types.
constructor(
componentId: string,
inputs: NodusPort[],
outputs: NodusPort[],
settings?: {
title?: string
width?: number | null
height?: number | null
isThinComponent?: boolean
isPortAutoLayoutEnabled?: boolean
}
)
| Member | Description |
|---|---|
id |
Auto-generated UUID |
inputs / outputs |
NodusPort arrays |
internalState |
Reactive { x, y, zIndex, width, height, title } |
compute() |
Override to propagate output values from inputs |
serialize() |
Override to return custom serializable data |
deserialize(data) |
Override to restore from serialized data |
setPosition(x, y) |
Set canvas position |
NodusPort
new NodusPort(type: string, color?: string, isMultiport?: boolean, defaultValue?: unknown)
| Member | Description |
|---|---|
id |
Auto-generated UUID |
type |
String tag used for connection type-matching |
color |
CSS color string |
ioType |
NodusPortType.Input or NodusPortType.Output (set by the graph) |
isMultiport |
Allow multiple incoming connections |
value |
Current reactive value |
valueRef |
Raw Vue Ref — use with computed/watch |
NodusConnection
new NodusConnection(sourcePort: NodusPort, targetPort: NodusPort, color?: string, connectionType?: string)
connectionType is a registry key naming the connection's edge style; it defaults to 'bezier' (see Connection types). Connections are created automatically when the user clicks two compatible ports. You only need to instantiate NodusConnection directly when restoring from serialized state.
NodusSerializer
Accessed via board.serializer.
| Member | Description |
|---|---|
serialize() |
Returns a plain JSON.stringify-safe object |
deserialize(data, factory) |
Restores graph state from a serialized object; factory is (componentId: string, data: any) => NodusBaseNode |
NodusHistory
Accessed via board.history. See Undo and redo for a walkthrough.
| Member | Description |
|---|---|
setNodeFactory(factory) |
Required before undo()/redo() — same factory shape as deserialize |
undo() / redo() |
Restore the previous / next state |
canUndo() / canRedo() |
Whether the respective stack is non-empty (reactive) |
records |
NodusHistoryRecord[] of undoable actions, oldest first, each with a label |
snapshot(label) |
Push the current state as an undo point (call before a programmatic mutation) |
captureSnapshot() |
Capture state without recording — pair with commitGesture() |
commitGesture(before, label) |
Record a bracketed gesture; no-op if nothing changed |
Component: VGraph
| Prop | Type | Required | Description |
|---|---|---|---|
board |
NodusBoard |
yes | The board instance to render |
theme |
Partial<NodusTheme> |
no | Override visual tokens (see Theming) |
Slots: default, #background (scoped: { panX, panY, zoom }) — render custom background content (a pattern, image, or canvas) behind the connections and nodes layers. Providing this slot replaces the built-in dot grid; leave it unused to keep the default grid.
Component: VBaseNode
Default node shell with a title bar and content area.
| Prop | Type | Default | Description |
|---|---|---|---|
node |
NodusBaseNode |
required | The node model |
isSelected |
boolean |
false |
Highlights the node with an outline |
Slots: #title (defaults to node.internalState.title), #content.
Component: VNodeRow
A content row that positions a port on the left or right edge.
| Prop | Type | Description |
|---|---|---|
inputPort |
NodusPort |
Port displayed on the left edge |
outputPort |
NodusPort |
Port displayed on the right edge |
Component: VPort
Renders a single port circle. Used internally by VBaseNode and VNodeRow. Can be placed manually in fully custom node layouts.
| Prop | Type | Required | Description |
|---|---|---|---|
port |
NodusPort |
yes | The port to render |
Feature requests
Got ideas or improvements? Open a feature request here:
👉 https://github.com/houseofbits/vue-nodus/issues/new?template=feature_request.md
License
MIT
Related Packages
Visual AI agent orchestration kit for Vue 3: a flow-editor canvas plus ready-mad...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.2.17 | ^3.5.0 | MIT | |
| 0.2.16 | ^3.5.0 | MIT | |
| 0.2.14 | ^3.5.0 | MIT | |
| 0.2.12 | ^3.5.0 | MIT | |
| 0.2.11 | ^3.5.0 | MIT | |
| 0.2.10 | ^3.5.0 | MIT | |
| 0.2.9 | ^3.5.0 | MIT | |
| 0.2.7 | ^3.5.0 | MIT | |
| 0.2.6 | ^3.5.0 | MIT | |
| 0.2.5 | ^3.5.0 | MIT | |
| 0.2.4 | ^3.5.0 | MIT | |
| 0.2.3 | ^3.5.0 | MIT | |
| 0.2.2 | ^3.5.0 | MIT | |
| 0.2.1 | ^3.5.0 | MIT | |
| 0.1.0 | ^3.5.0 | MIT |