@macrulez/visual-linker-vue
v0.4.3
Published
Vue 3 component and composable for drawing smart, auto-routed connector lines between DOM blocks
Readme
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 - 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) 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-vueRegister 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.
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.
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.
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-themesEvery 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. ❤️
