@macrulez/visual-linker-vue
Visual Linker Vue

Vue 3 component and composable for drawing smart, auto-routed SVG
connector lines between DOM blocks you already control, built on top of
@macrulez/visual-linker-core.
SSR-safe: the engine is only ever created client-side, once mounted.
Part of the visual-linker
monorepo. See also
@macrulez/visual-linker-nuxt
if you're on Nuxt, and the
playground
for a live example (curve types, port routing, markers, drag & drop, live
option tuning).
Features
<VisualLinker>around your own markup — put any template in its default slot; blocks can sit at any depth, inside any wrapper components. No per-block wrappers, no per-block slots- Three ways to mark blocks and ports — the
v-vl-block/v-vl-portdirectives, plaindata-vl-*attributes, or theblocksprop with a template ref / getter / CSS selector — mix them freely - Two scopes —
scope="container"(default) draws inside the component's own box;scope="page"links elements anywhere in the document, drawing in a fixed layer teleported to<body> - Live discovery — blocks and ports added, removed or re-marked later (
v-if,v-for, third-party markup) are picked up automatically - Ref/getter-friendly everywhere — a block's
el,dragHandle,dragBounds, and a port'starget/anchorElall accept a Vue template ref directly - Creating connections —
@connection-create-request/@connection-create-cancel/@connect-start,connectonv-vl-block/v-vl-port(ordata-vl-connect), andstartConnect/completeConnect/cancelConnect/isConnectingthrough a template ref - Fallback ends and big diagrams —
fallbackon a connection end, incremental block registry, id-basedconnectionsdiff,batchandstats() - Three overlay slots —
#connection-label,#port,#marker— HTML content positioned exactly where the engine's own SVG drawing puts each connection/port useVisualLinker()— the low-level composable, wired straight to a container element you render yourself- A reactive
config— one structured configuration (theme,lines,markers,ports,labels,blocks,interaction,performance) that follows your state: change the prop, a ref or the app-wide shared config (VisualLinkerPlugin'sconfig,useVisualLinkerConfig()) and every diagram re-renders live. Nothing is global: the shared config is aprovideof the app (or a subtree) - The full
@macrulez/visual-linker-coresurface, re-exported —createVisualLinker, every enum and every type, no separate core install needed - SSR-safe by design — the engine and its drawing layer only exist client-side after mount; the directives emit their
data-vl-*attributes during SSR too
When you'd reach for this
- Connector lines on top of a layout you already have — cards inside panels inside grid columns: mark the cards with
v-vl-block, keep your components and CSS as they are. - Rows or handles inside a block as connection points —
v-vl-porton the row; it attaches to the nearest block around it. - Elements in completely different parts of the page — a sidebar list and a main area rendered by different components:
scope="page"connects them without restructuring anything. - Blocks the user can drag around —
config.blocks.draggable,dragHandle/dragBounds, and every connected line follows in real time. - A label or custom marker that must sit exactly on a connection —
#connection-label/#markerslots use the same per-render geometry as the SVG lines.
Installation
Requires Vue ^3.3.0.
npm install @macrulez/visual-linker-vue
Register the component and directives globally:
import { createApp } from 'vue'
import { VisualLinkerPlugin } from '@macrulez/visual-linker-vue'
createApp(App).use(VisualLinkerPlugin).mount('#app')
…or import them per component — in <script setup>, vVlBlock/vVlPort become v-vl-block/v-vl-port automatically:
import { VisualLinker, vVlBlock, vVlPort } from '@macrulez/visual-linker-vue'
Quick start
<script setup lang="ts">
const connections = [{ id: 'a-b', from: { blockId: 'a' }, to: { blockId: 'b' } }]
</script>
<template>
<VisualLinker :connections="connections">
<MyLayout>
<MyCard v-vl-block="'a'" />
<SidePanel>
<div data-vl-block="b">Plain HTML works too</div>
</SidePanel>
</MyLayout>
</VisualLinker>
</template>
Marking blocks and ports
All three methods feed the same registry and can be mixed in one diagram.
1. Directives
<div v-vl-block="{ id: 'b13', draggable: true, dragHandle: '.title', dragBounds: 'container' }">
<div class="title">Drag me</div>
<!-- belongs to the nearest block around it -->
<div v-vl-port="{ id: 'row1', side: ['left', 'right'], anchorBlockId: 'b13' }">Row 1</div>
</div>
<!-- or attach a port to a block explicitly -->
<span v-vl-port="{ id: 'out', block: 'b13' }" />
v-vl-block="'b13'" / v-vl-port="'row1'" is the shorthand for an id with no options.
2. Data attributes — no JavaScript at all, handy for server-rendered or third-party markup:
| Attribute | On | Meaning |
|---|---|---|
data-vl-block="id" |
block | registers the element as a block |
data-vl-draggable |
block | ""/"true" → draggable, "false" → not |
data-vl-drag-handle=".sel" |
block | drag handle, a selector inside the block |
data-vl-drag-bounds="container" |
block | container, or a CSS selector of the fence element |
data-vl-port="id" |
port | registers a port on the nearest block around it |
data-vl-port-block="id" |
port | …or on this block explicitly |
data-vl-side="left right" |
port | one side, or a space/comma-separated candidate list |
data-vl-offset="0.3" |
port | position along the side, 0..1 |
data-vl-anchor="id" |
port | anchorBlockId — draw on that block's border instead |
data-vl-port-spread="24 8" |
block | portSpread: "" on, "false" off, or gap [padding] |
data-vl-spread="false" |
port | the port's own spread, same values |
data-vl-linker="name" |
block/port | assign to the <VisualLinker name="…"> with this name |
3. The blocks prop — for refs held in <script>, or elements you can't add attributes to:
const cardRef = useTemplateRef('card')
const blocks = [
{ id: 'a', el: cardRef, ports: [{ id: 'p', target: rowRef }] }, // a template ref
{ id: 'b', el: '#legacy-widget' }, // a CSS selector
{ id: 'c', draggable: false }, // no el: extra config for a block marked in the template
]
For the same id, the blocks prop wins over directive options, which win over data attributes.
Drag offsets are applied with the CSS
translateproperty, so they compose with anytransforma block already has. Avoid a string:stylebinding on a draggable block — Vue replaces the whole inline style whenever that string changes.
scope: where blocks can live
<!-- default: blocks anywhere inside the component; lines drawn inside its own box -->
<VisualLinker :connections="connections">…</VisualLinker>
<!-- blocks anywhere in the document -->
<VisualLinker scope="page" name="assign" :connections="connections" :z-index="10" />
<TaskList><li v-vl-block="{ id: 't1', linker: 'assign' }">…</li></TaskList>
<OwnerList><li data-vl-block="ann" data-vl-linker="assign">…</li></OwnerList>
Ownership rules: an explicit data-vl-linker / linker name wins; otherwise an element belongs to the nearest enclosing <VisualLinker> (so nested instances never take each other's blocks); an element outside every <VisualLinker> belongs to page-scoped instances. With several page-scoped instances on one page, give each a name.
In page scope the lines are drawn in a position: fixed layer covering the viewport, teleported to <body> — so ancestors' overflow: hidden or transform can't clip or offset it; zIndex sets its stacking order. dragBounds: 'container' then means the viewport.
<VisualLinker> props
| Prop | Type | |
|---|---|---|
connections |
ConnectionDescriptor[] |
required |
blocks |
VisualLinkerBlock[] |
optional — id, el? (ref/getter/element/selector), ports, draggable, dragHandle, dragBounds |
config |
VisualLinkerConfig |
the diagram's configuration, reactive — merged over the shared defaults; see @macrulez/visual-linker-core |
scope |
'container' | 'page' |
default 'container' |
name |
string |
lets elements elsewhere claim this instance via data-vl-linker / the directives' linker |
selected |
string[] |
ids of selected connections (v-model:selected), needs config.interaction.selectable |
zIndex |
number | string |
stacking order of the drawing layer |
Emits mirror the engine's own events 1:1, kebab-cased: block-dragstart,
block-drag, block-dragend, block-mouseenter, block-mouseleave,
connection-click, connection-mouseenter, connection-mouseleave, plus
connection-selectionchange, connection-delete-request, connect-start,
connection-create-request and connection-create-cancel.
Selecting connections
<VisualLinker
v-model:selected="selected"
:connections="connections"
:config="{ interaction: { selectable: true } }"
@connection-delete-request="(requested) => remove(requested)"
>
…
</VisualLinker>
selected is an array of connection ids; leave it unset to let the engine own
the selection. @connection-selectionchange receives the same array,
@connection-delete-request the connections to delete (Delete/Backspace on a
focused line) — you decide whether to remove them. See the core README for the
full keyboard/ARIA behaviour and style.selected.
Creating connections
<VisualLinker
ref="linker"
:connections="rules"
:config="{ interaction: { connect: { enabled: true, validate } } }"
@connection-create-request="({ from, to }) => rules.push({ id: `${from.portId}>${to.portId}`, from, to })"
>
<div v-vl-block="'source'">
<div v-vl-port="{ id: 'email', side: 'right' }">email</div>
</div>
<div v-vl-block="'target'">
<div v-vl-port="{ id: 'mail', side: 'left', connect: 'target' }">mail</div>
</div>
</VisualLinker>
The library never adds the connection: you do, from the event. linker.value.startConnect(...),
completeConnect(...), cancelConnect(), isConnecting(), batch(fn) and stats() are exposed on the template ref;
useVisualLinker() returns the same functions. The component updates its block registry from the directive hooks
(200 blocks mounted in one tick are one update) and sends the engine only the connections that changed.
Overlay slots
<template>
<VisualLinker :connections="connections">
<div v-for="item in items" :key="item.id" v-vl-block="item.id" class="card">{{ item.id }}</div>
<template #connection-label="{ connection, point }">
<span class="badge">{{ connection.id }}</span>
</template>
<template #marker="{ position }">
<span v-if="position === 'end'" class="dot" />
</template>
</VisualLinker>
</template>
With labels on a connection (see the core README) #connection-label is called
once per label without text — { connection, label, point, angle, rotation } —
while labels with text are drawn by the engine; without labels it is called
once, at the line's midpoint, as before.
Each slot is only built if actually used. #marker only renders for an
endpoint with no marker configured (neither config.markers nor the connection's
style.markers), and not for an end pinned to a scroller's edge — an explicit marker still wins.
The #port slot is rendered for every connectable port (with ports.show: 'all', connected or not) and receives
{ blockId, portId, point, role, connected, state, hovered }; ports.slotPointerEvents: false makes its content ignore
the pointer.
useVisualLinkerConnect(linker)
import { useVisualLinkerConnect } from '@macrulez/visual-linker-vue'
const linker = ref(null) // template ref of <VisualLinker>, its engine, or an engine
const { connecting, from, candidate, via } = useVisualLinkerConnect(linker)
The connect gesture as refs: connecting, the endpoint it from, the candidate under the pointer and how it started
(via: 'drag', 'click', 'keyboard', 'api'). They reset when the request or the cancel arrives.
useVisualLinker(container, options?)
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useVisualLinker } from '@macrulez/visual-linker-vue'
const containerEl = ref<HTMLElement | null>(null)
const blockAEl = ref<HTMLElement | null>(null)
const blockBEl = ref<HTMLElement | null>(null)
const blocks = computed(() => [
{ id: 'a', el: blockAEl },
{ id: 'b', el: blockBEl },
])
const connections = ref([{ id: 'a-b', from: { blockId: 'a' }, to: { blockId: 'b' } }])
const config = ref({ lines: { curve: 'smoothstep' } }) // a ref, a getter or a plain object; changes apply live
const { engine } = useVisualLinker(containerEl, { blocks, connections, config })
</script>
<template>
<div ref="containerEl" style="position: relative">
<div ref="blockAEl">A</div>
<div ref="blockBEl">B</div>
</div>
</template>
engine is a ShallowRef<VisualLinker | null> — null until mount. Omit
options.blocks/options.connections to manage them yourself via
engine.value.setBlocks(...)/setConnections(...) instead of the
reactive sync.
Reactive configuration and the shared config
config is watched: replace the prop, mutate a reactive object, or change a
ref given to useVisualLinker(), and the diagram re-renders with the new
values — switching a theme or toggling lines.jumps needs no remount.
A configuration shared by every diagram of the app is a plain Vue provide:
import { createApp } from 'vue'
import { VisualLinkerPlugin, darkTheme } from '@macrulez/visual-linker-vue'
createApp(App).use(VisualLinkerPlugin, { config: { theme: darkTheme, lines: { curve: 'smoothstep' } } })
import { useVisualLinkerConfig, lightTheme } from '@macrulez/visual-linker-vue'
const shared = useVisualLinkerConfig() // a reactive VisualLinkerConfig
shared.theme = lightTheme // every diagram under the plugin re-themes
Every diagram merges the shared config under its own config, field by
field, with its own config winning — so one change re-styles all of them at
once. The state belongs to the app (nothing is global, several apps on a page
don't interfere), and provideVisualLinkerConfig(initial?) does the same for
a subtree: components below it use that config instead of the app-level one.
useVisualLinkerConfig() throws a clear error if nothing provides one. This is
what the Nuxt module's options feed.
Documentation & links
- 📖 Full documentation: npm.vuecraft.ru/en/packages/visual-linker
- 🌐 VueCraft: vuecraft.ru/en
- 👤 Author: macrulez.ru/en
- 💻 GitHub: macrulezru/visual-linker/packages/vue
- 📦 NPM: @macrulez/visual-linker-vue
- 🐛 Issues: github.com/macrulezru/visual-linker/issues
License
MIT
💖 Support the project
Open source takes time and effort. If this library saves you time or brings value, consider supporting further development.
Thank you for being part of this journey. ❤️
Related Packages
Reactive Vue 3 forms generated from JSON, Zod, Yup or Valibot schemas, with vali...
Reactive finite state machines and statecharts for Vue 3 — declarative transitio...
Headless Command+K palette for Vue 3 with fuzzy search, grouped and nested comma...
Nuxt module wrapping @macrulez/inview-vue: auto-imported composables/core engine...
Version History
| Version | Released | License |
|---|---|---|
| 0.4.6 | MIT | |
| 0.4.5 | MIT | |
| 0.4.3 | MIT | |
| 0.4.2 | MIT | |
| 0.4.1 | MIT | |
| 0.4.0 | MIT | |
| 0.3.0 | MIT | |
| 0.2.0 | MIT | |
| 0.1.0 | MIT |