NbBlueprint is a visual node editor canvas with built-in card dragging, multi-selection, alignment, distribution, auto-layout, and animated bezier wires. It has no domain logic: the parent owns card data, positions, and connections.
Interaction model
| Action | Effect |
|---|---|
| Left drag on canvas | Marquee (box) select |
| Shift + marquee | Add to selection |
| Click on card | Select card (deselects others) |
| Shift + click on card | Toggle card in selection |
| Drag a selected card | Move all selected cards |
| Two-finger scroll | Pan the canvas |
| Middle mouse drag | Pan the canvas |
| Space + left drag | Pan the canvas |
| Pinch (trackpad) | Focal-point zoom |
| Ctrl + scroll | Focal-point zoom |
| Drag from port to port | Connect two cards |
| Right-click a wire | Context menu (Disconnect, etc.) |
Cards are keyboard-operable too. Tab reaches a card and its controls, arrow keys move it (with Shift for a coarse step), and Enter on a port starts a connection that a second Enter on another port completes. See Keyboard and assistive technology.
Basic example
Cards are placed in the default slot with transform: translate(x, y) positioning. The parent keeps the connections array and reacts to connect, disconnect, and move events.
<template>
<div style="height: 480px;">
<NbBlueprint
ref="blueprint"
:connections="connections"
@connect="onConnect"
@disconnect="onDisconnect"
@move="onMove"
>
<div
v-for="card in cards"
:key="card.id"
:style="{
position: 'absolute',
transform: `translate(${card.x}px, ${card.y}px)`,
}"
>
<NbBlueprintCard
:id="card.id"
:title="card.title"
:category="card.category"
:color="card.color"
:ports="card.ports"
:connected-ports="connectedPortsFor(card.id)"
:selected="blueprint?.selectedIds?.has(card.id)"
:focused="blueprint?.focusedId === card.id"
/>
</div>
</NbBlueprint>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { IBlueprintConnection, IBlueprintCardMove } from '@nubisco/ui'
const blueprint = ref()
// Handle card moves (the consumer owns position data)
function onMove(moves: IBlueprintCardMove[]) {
for (const m of moves) {
const card = cards.value.find((c) => c.id === m.id)
if (card) {
card.x = m.x
card.y = m.y
}
}
}
</script>Windowed rendering (large graphs)
The basic example above hands cards to the default slot, so every card is mounted all the time. That is fine up to a hundred or so cards. Past that, the layout, paint, and compositing cost of all those off-screen DOM subtrees starts to show up as sluggish panning and dragging.
For large graphs, pass card geometry as the cards prop and render each card through the #card scoped slot instead. Blueprint then owns the v-for and the position wrappers, and only mounts the cards whose box (or a wire crossing the viewport) is actually on screen. Off-screen cards are never instantiated, so render cost tracks what's visible, not the total node count.
<template>
<div style="height: 480px;">
<NbBlueprint
ref="blueprint"
:cards="cards"
:connections="connections"
@move="onMove"
>
<template #card="{ card }">
<NbBlueprintCard
:id="card.id"
:title="card.title"
:ports="card.ports"
:selected="blueprint?.selectedIds?.has(card.id)"
:focused="blueprint?.focusedId === card.id"
/>
</template>
</NbBlueprint>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { IBlueprintCard, IBlueprintCardMove } from '@nubisco/ui'
// Cards carry their own geometry. Width/height are optional but make the
// off-screen cull tighter; extra fields (title, ports, ...) ride along and
// come back through the #card slot untouched.
const cards = ref<IBlueprintCard[]>([
{ id: 'osc', x: 40, y: 60, width: 220, height: 160 /* , title, ports */ },
// ...
])
function onMove(moves: IBlueprintCardMove[]) {
for (const m of moves) {
const card = cards.value.find((c) => c.id === m.id)
if (card) {
card.x = m.x
card.y = m.y
}
}
}
</script>Notes:
- Mutually exclusive with the default slot. When
cardsis set, the default slot is ignored. Use one API or the other. - You still own card data and positions. Handle
moveand fold it back intocardsexactly as in the default-slot API. That round trip is what keeps a dragged card glued to the cursor. - Wires never vanish mid-canvas. A wire whose path crosses the viewport keeps both its endpoint cards mounted even when one end is scrolled far off, so the visible part of the wire still draws.
- Sizing. Give
width/heightwhen you know them for the tightest cull. OtherwisecardSizeEstimate(or a built-in default) is used; the overscan band means a loose estimate just mounts a few extra edge cards rather than dropping visible ones. fitToView,centerView,autoLayout, andselectAlloperate over the fullcardsprop, not just the mounted subset, so they behave the same as in the non-windowed API.
Drag-to-connect
Cards rendered inside an NbBlueprint automatically wire their port mousedown/mouseup events to the blueprint via Vue provide/inject. No parent boilerplate needed: drop NbBlueprintCard in the default slot, and dragging from a port to another compatible port emits connect on the blueprint.
The card still emits port-mousedown and port-mouseup so consumers using NbBlueprintCard outside an NbBlueprint (in a docs page, isolated demo, etc.) can wire the events manually if needed.
Drop targets
While a wire is in flight the blueprint publishes the port it started from, and every card lights the pins that could accept it and dims the ones that could not. This needs no wiring up and no host state: the answer arrives while the user is still aiming rather than on release.
A pin can accept a wire when it is on a different card, faces the other way, and carries a compatible dataType. Compatibility rules and the family matching are documented under Drop targets on the card page.
Wire context menu
Right-clicking a wire opens a small context menu anchored at the cursor. The default menu shows a single Disconnect action that emits disconnect on the blueprint with the connection. Esc or a click outside closes the menu.
To replace or extend the menu, use the wire-menu slot. The slot scope exposes the connection plus close() and disconnect() helpers:
<NbBlueprint :connections="connections">
<template #wire-menu="{ connection, close, disconnect }">
<button @click="disconnect()">Disconnect</button>
<button @click="onInsertNode(connection); close()">Insert node…</button>
</template>
<!-- cards go here -->
</NbBlueprint>Card dragging
Cards are now draggable directly. When you drag a selected card, all selected cards move together. When you drag an unselected card, it becomes selected first.
On drag end, the blueprint emits move with an array of { id, x, y } objects. The consumer should update their data model with these positions so they persist across re-renders.
Focus and selection
The Blueprint distinguishes between two concepts:
- Focused card (single): the card the user last clicked. Clients use this for inspector panels, property editing, or any single-card context. The
focusevent emits the card ID (ornull). Access via the exposedfocusedIdref. - Selected cards (one or more): the set of cards that move, align, and distribute together. Used for spatial operations. The
selection-changeevent emits the full ID array. Access via the exposedselectedIdsref.
Clicking a card both focuses and selects it. Shift+click adds or removes cards from the selection without changing focus.
- Click a card to focus and select it (deselects others).
- Shift+click to add or remove a card from the selection.
- Left-drag on empty canvas draws a marquee rectangle. Cards intersecting the rectangle are selected.
- Shift+marquee adds to the existing selection.
selectAll()anddeselectAll()are exposed for toolbar buttons or keyboard shortcuts.
Alignment and distribution
When multiple cards are selected, you can align or distribute them using exposed methods:
| Method | Effect |
|---|---|
alignLeft() | Align selected cards to the leftmost edge |
alignCenter() | Align selected cards to the average horizontal center |
alignRight() | Align selected cards to the rightmost edge |
alignTop() | Align selected cards to the topmost edge |
alignMiddle() | Align selected cards to the average vertical center |
alignBottom() | Align selected cards to the bottommost edge |
distributeHorizontally() | Space selected cards evenly along the X axis |
distributeVertically() | Space selected cards evenly along the Y axis |
All alignment/distribution methods emit move so the consumer can persist the new positions.
Auto-layout
autoLayout() arranges all cards in a layered left-to-right layout based on their connections (topological ordering). Cards within each layer are sorted by category for visual grouping.
<NbButton @click="blueprint?.autoLayout()">Auto layout</NbButton>
<NbButton
@click="blueprint?.autoLayout({ gapX: 120, gapY: 60 })"
>Wider</NbButton>Options: { gapX?: number, gapY?: number, padding?: number } (defaults: 80, 40, 60).
View controls
| Method | Effect |
|---|---|
fitToView(padding?) | Scale zoom so all cards fit the viewport (default 40px padding) |
centerView() | Reset zoom to 1x, center cards |
resetView() | Reset pan to 0,0 and zoom to 1x |
Panning and zooming
Panning: two-finger scroll (trackpad), middle mouse drag, or Space + left drag. This keeps left drag free for marquee selection and card dragging.
The Space + drag gesture is suspended whenever a text input, textarea, or contenteditable element has focus elsewhere on the page — typing a space into an inspector field next to the canvas keeps working. Click on the canvas (or anywhere outside a text-entry surface) to re-enable Space-drag pan.
Zooming: pinch (trackpad) or Ctrl + scroll wheel. Both use focal-point zoom centered on the cursor position, clamped between 0.2x and 3x.
The wheelMode prop overrides what plain wheel events do without affecting the pinch-to-zoom path:
'auto'(default): plain wheel pans, pinch zooms. Matches the gestures above.'zoom': every wheel event becomes a cursor-anchored zoom. Right for node-editor surfaces where panning has its own gesture (e.g. Space + drag) and the wheel is the natural zoom verb.'pan': every wheel event pans, never zooms. Use when zoom should be exclusively gesture-driven.
Wire animation modes
Wires can be static, always-animating, or signal-driven. Pick a policy with animateConnections:
'never'(default) — static wires; no flow overlay, no colour shift. Cheapest option; right for graphs that don't carry signal.'always'— every wire animates continuously. Visual cue that the graph is "live" without per-wire bookkeeping.'on-activity'— animate iffconnection.active === true. Inactive wires stay visible but dim and don't animate. Matches an audio host where the parent ships a per-wire boolean that flips when peak crosses a threshold.'levels'— same activity gating as'on-activity', plus audio wires colour-shift green → yellow → red based onconnection.level(0..1). MIDI wires (or wires whoselevelis undefined) keep their card-accent colour and behave like'on-activity'.
The level field is a linear amplitude in [0, 1]. Level 0 maps to green, 0.5 to yellow, 1.0 to red, with linear blending between anchors. The host owns the smoothing — typically a peak meter with capacitor decay produces stable, readable levels at 16–60 Hz update rates.
Drop-on-wire
A single-card drag that ends with the cursor over a wire's hit-region fires drop-on-wire with the dragged card id and the wire's connection. Multi-card drags never fire this event — the gesture is "pick up THIS card and drop it on a wire", not "move a selection over a wire".
The component only emits the gesture; the host decides what to do with it. The typical handler splices the dragged card into the wire (channel-matched for parallel bundles, e.g. L+R), but other interpretations (e.g. attach a probe, branch a tap) are valid too.
To give the user a live hint before the drop, subscribe to wire-hover. It fires DURING a single-card drag whenever the wire under the cursor changes (entry, exit, or transition to a different wire). The payload is the dragged card id plus the new wire (or null when the cursor leaves the last wire). The event is de-duped — it fires on transitions, not on every mousemove tick — so subscribers can put expensive work (e.g. a splice-preview tooltip) in the handler without watching for performance.
<NbBlueprint
:connections="connections"
@drop-on-wire="onSplice"
@wire-hover="(cardId, conn) => (hoverWire = conn)"
/>Renderer
Blueprint draws the camera-transformed scene (grid, wires, cards) through a swappable renderer, chosen with the renderer prop. The public API (props, events, exposed methods, the useBlueprint() controller) is identical across renderers, so you can switch without touching host code.
'auto'(default) — use the PixiJS (WebGL) renderer when a WebGL-capable client renderer is available, otherwise the DOM/SVG renderer. Server-side rendering always uses DOM.'dom'— force the DOM/SVG renderer.'pixi'— force the PixiJS (WebGL) renderer. Falls back to DOM with a console warning when it is not available.
<NbBlueprint :cards="cards" :connections="connections" renderer="auto" />PixiJS renderer
The PixiJS renderer targets large graphs (thousands of cards) where DOM/SVG pan and zoom become the bottleneck. It moves the grid, wires, and card visuals onto a single WebGL canvas, so a pan or zoom is a camera-matrix update plus GPU compositing instead of re-rasterizing a large DOM layer.
It is an opt-in optional peer dependency. Install PixiJS v8 to use it:
pnpm add pixi.jsWithout pixi.js installed (or without WebGL, or during SSR), 'auto' and 'pixi' resolve to the DOM renderer; an explicit renderer="pixi" logs a one-time console warning and falls back. So the default 'auto' is always safe.
How it behaves:
- At rest and at readable zoom, the real DOM cards (your
#cardslot) are shown and fully interactive: drag, multi-select, marquee, wire hover/menu all work exactly as in the DOM renderer. PixiJS draws the grid and wires behind them. - While panning/zooming, and at far zoom, the DOM card layer is hidden and PixiJS paints the whole scene (grid, wires, and cards). This is where the gesture stays smooth on big graphs.
Because interaction always happens on the DOM cards, every existing event and the useBlueprint() controller behave identically. One consequence of the level-of-detail design: below the far-zoom threshold individual cards are painted by PixiJS and are not separately clickable (zoom in to interact); marquee selection and panning still work at any zoom.
Native card painting (paint)
PixiJS cannot run your Vue #card component on the GPU, so for the during-gesture and far-zoom view it paints cards itself from a structured descriptor. In the windowed (cards) API, give each card an optional paint field (or set the well-known fields title, color, ports directly on the card object) so the painter can draw a faithful card:
import type { IBlueprintCard, IBlueprintCardPaint } from '@nubisco/ui'
const cards: IBlueprintCard[] = [
{
id: 'osc',
x: 40,
y: 60,
width: 220,
height: 160,
paint: {
title: 'Oscillator',
color: '#a855f7',
ports: [
{ id: 'out', label: 'out', type: 'output' },
{ id: 'sync', label: 'sync', type: 'input' },
],
} satisfies IBlueprintCardPaint,
},
]If paint (and the loose fallbacks) are omitted, far/gesture cards render as a plain accent-colored box with no text. The DOM card (your #card slot) is always the source of truth at readable zoom, so paint only affects the zoomed-out and in-motion view.
Background
The canvas pattern is set with the background prop: 'dots' (default), 'lines', 'grid' (a ruled grid with heavier major lines every few cells), or 'none'. Color and spacing are themable with CSS variables, so changing the look is a one-liner:
Scroll to pan and pinch (or Ctrl + scroll) to zoom — the background tracks the camera with the scene. The last button swaps the built-in pattern for a custom NbBlueprintBackground in the #background slot, which suppresses the prop automatically.
<NbBlueprint :cards="cards" background="grid" />.nb-blueprint {
--nb-blueprint-grid-color: rgba(255, 255, 255, 0.06);
--nb-blueprint-grid-gap: 32px;
}The prop applies to both renderers (the DOM renderer draws a CSS pattern; the PixiJS renderer draws a tiled texture), and is the most efficient option.
For full control — per-instance colours, gap, thickness, a custom secondary grid, or an entirely custom backdrop — drop NbBlueprintBackground (or any element) into the #background slot instead. See Background.
Theming
The canvas background uses --nb-c-layer-0. Ambient gradients are configurable via --nb-blueprint-ambient-1 and --nb-blueprint-ambient-2 (set to transparent to disable). Wire colors are derived from each source card's --nb-card-color.