@macrulez/visual-linker-vue

Vue 3 component and composable for drawing smart, auto-routed connector lines between DOM blocks
1,235
Install
npm install @macrulez/visual-linker-vue
Latest Version:0.4.6
Vue:3
License:MIT
Last Updated:Oct 22, 2026
Links: npm
Maintainer: 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-port directives, plain data-vl-* attributes, or the blocks prop 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's target/anchorEl all accept a Vue template ref directly
  • Creating connections — @connection-create-request / @connection-create-cancel / @connect-start, connect on v-vl-block / v-vl-port (or data-vl-connect), and startConnect / completeConnect / cancelConnect / isConnecting through a template ref
  • Fallback ends and big diagrams — fallback on a connection end, incremental block registry, id-based connections diff, batch and stats()
  • 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's config, useVisualLinkerConfig()) and every diagram re-renders live. Nothing is global: the shared config is a provide of the app (or a subtree)
  • The full @macrulez/visual-linker-core surface, 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-port on 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/#marker slots 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 translate property, so they compose with any transform a block already has. Avoid a string :style binding 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


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

@macrulez/vue-form-schema

Reactive Vue 3 forms generated from JSON, Zod, Yup or Valibot schemas, with vali...

2,714 0
@macrulez/vue-state-machine

Reactive finite state machines and statecharts for Vue 3 — declarative transitio...

2,677 0
@macrulez/vue-command-palette

Headless Command+K palette for Vue 3 with fuzzy search, grouped and nested comma...

2,330 0
@macrulez/inview-vue

Vue 3 composables for scroll, visibility and viewport-position tracking

1,014
@macrulez/inview-nuxt

Nuxt module wrapping @macrulez/inview-vue: auto-imported composables/core engine...

963

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