Interaction Choreography

Node Wire Canvas

A node-based workflow canvas: freely draggable nodes, bezier wires between ports, drag-to-connect from any port, flowing pulses along edges while running, plus pan and zoom.

This is a WebberUI Pro component

The install command below needs a licence to run; without one it returns 401.

How to install Pro components →See the plans →

Turn a multi-step pipeline into a graph you can edit in place. Each node is a card with input ports on the left and output ports on the right, and ports are joined by cubic bezier wires. Drag a node anywhere and it springs onto the grid when released; drag a new wire out of an output port and it snaps to a valid input port as you get close; while the flow runs, pulses travel along every wire. The canvas pans with the wheel or by dragging empty space and zooms with Ctrl / ⌘ + wheel, and both nodes and edges are controllable data.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add "https://webberui.com/r/node-wire-canvas.en.json?t=<install token>"

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

400
1.6
Active edited usageDefault props
<NodeWireCanvas/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add "https://webberui.com/r/node-wire-canvas.en.json?t=<install token>"

Or, once registries are configured in components.json, install it as @webberui/node-wire-canvas.

Usage

import * as React from "react";
import {
  NodeWireCanvas,
  type NodeWireEdge,
  type NodeWireNode,
} from "@/components/ui/node-wire-canvas";

const initialNodes: NodeWireNode[] = [
  { id: "prompt", x: 0, y: 0, title: "文字提示", outputs: [{ id: "prompt" }] },
  {
    id: "gen",
    x: 280,
    y: 40,
    title: "影像生成",
    inputs: [{ id: "prompt" }],
    outputs: [{ id: "image" }],
  },
];

const initialEdges: NodeWireEdge[] = [
  { id: "e1", from: { node: "prompt", port: "prompt" }, to: { node: "gen", port: "prompt" } },
];

export function Workflow() {
  const [nodes, setNodes] = React.useState(initialNodes);
  const [edges, setEdges] = React.useState(initialEdges);
  const [running, setRunning] = React.useState(false);

  return (
    <NodeWireCanvas
      nodes={nodes}
      onNodesChange={setNodes}
      edges={edges}
      onEdgesChange={setEdges}
      onConnect={(edge) => console.log("新連線", edge.id)}
      showFlow={running}
    />
  );
}

Props

PropTypeDefaultDescription
nodesNodeWireNode[]—Node list (controlled)
defaultNodesNodeWireNode[][]Initial node list (uncontrolled)
onNodesChange(nodes: NodeWireNode[]) => void—Called with the full new list when nodes move or are deleted
edgesNodeWireEdge[]—Edge list (controlled)
defaultEdgesNodeWireEdge[][]Initial edge list (uncontrolled)
onEdgesChange(edges: NodeWireEdge[]) => void—Called with the full new list when edges are added or deleted
onConnect(edge: NodeWireEdge) => void—Called when the user creates a new edge
showFlowbooleanfalseShow flowing pulses on every edge; when off, only edges whose source node is running flow
snapbooleantrueSnap nodes to the grid on release
gridSizenumber20Grid spacing (px); also the arrow-key nudge step
stiffnessnumber400Spring stiffness of the snap-to-grid settle; higher settles faster
flowDurationnumber—Seconds for a pulse to travel one edge; falls back to --wb-nwc-flow-duration (1.6s)
minZoomnumber0.4Minimum zoom
maxZoomnumber2Maximum zoom
defaultViewportNodeWireViewport—Initial viewport; when omitted, all nodes are fitted into view on mount
readOnlybooleanfalseRead-only: no node dragging, connecting or deleting; pan and zoom still work
heightnumber480Canvas height (px)
ariaLabelstring"工作流程畫布" (Workflow canvas)Accessible name of the canvas
classNamestring—Extra classes merged onto the root element

NodeWireNode

FieldTypeDefaultDescription
idstring—Unique node id
xnumber—Card top-left x (canvas coordinates)
ynumber—Card top-left y (canvas coordinates)
titlestring—Title bar text
inputsNodeWirePort[][]Input ports on the left
outputsNodeWirePort[][]Output ports on the right
status"idle" | "running" | "done" | "error""idle"Node status: grey dot, pulsing dot with a marching dashed outline, green check, red cross
contentReactNode—Card body, rendered below the port rows

NodeWirePort

FieldTypeDefaultDescription
idstring—Port id, unique within its node
labelstring—Display label; falls back to id

NodeWireEdge

FieldTypeDefaultDescription
idstring—Unique edge id; edges created by the user get "sourceNode.port->targetNode.port"
from{ node: string; port: string }—Source (an output port)
to{ node: string; port: string }—Target (an input port)

NodeWireViewport

FieldTypeDefaultDescription
xnumber—Horizontal pan (px)
ynumber—Vertical pan (px)
zoomnumber—Zoom factor

CSS variables

VariableDefaultDescription
--wb-nwc-accent#6366f1Accent: selection ring, wire preview, running status dot
--wb-nwc-flow-colorsame as --wb-nwc-accentPulse and glow colour
--wb-nwc-flow-duration1.6sTime for a pulse to travel one edge
--wb-nwc-march-duration0.8sOne loop of the running outline dashes
--wb-nwc-dotrgb(163 163 163 / 0.5)Background dot grid colour

How it works

  • Coordinate system: the content layer expresses the whole canvas as translate(panX, panY) scale(zoom), and the background dot grid spacing is gridSize × zoom so it tracks pan and zoom. The plain wheel pans, Ctrl / ⌘ + wheel (including trackpad pinch) zooms around the cursor, and dragging empty space pans too, matching common design tools
  • Port positions come from data: port coordinates are computed straight from the node x / y and port index rather than measured from the DOM, so connected wires can be recomputed every frame during a drag without layout thrash. The drag itself is computed from pointer events (delta divided by zoom) and written back to data, not just applied as a transform
  • Snap to grid: with snap on, releasing a node writes the snapped position to data first, then useSpring (tunable via stiffness) tweens the displayed position onto the grid, with wires following along
  • Wires: each edge is a cubic bezier M p1 C p1+dx p2−dx p2 with dx = max(60, |Δx| / 2). Dragging out of an output port shows a dashed preview that snaps and highlights within 16px of a valid input port; self-connections and duplicates are rejected. Select a wire and press Delete to remove it
  • Flow: when showFlow is true, or an edge's source node is running, three pulses travel along that edge via CSS offset-path on the same path, staggered by a third of a cycle; they pause while the canvas is scrolled out of view
  • How it differs from patchbay-integrations: the patchbay is a fixed two-column integration board; here nodes can be laid out freely and edges are data you can add, delete and control — a fit for workflow editors

Accessibility

  • The canvas is role="application" with an aria-label (default "工作流程畫布", Workflow canvas) and sr-only instructions; creating, deleting or cancelling a connection is announced through aria-live
  • Nodes are Tab-focusable (role="group", named with title and status); arrow keys move one grid step, Shift plus arrow keys moves five, and Delete removes the node with its wires; all disabled when readOnly
  • Ports are buttons: press Enter on an output port to enter connect mode, Tab to a target input port and press Enter to finish, Esc cancels; with a mouse you can also click an output port, then an input port. Each wire is Tab-focusable too and Delete removes it
  • The + and - keys zoom and 0 resets the view; the corner toolbar has Zoom out, Zoom in and Fit all buttons
  • When the user's system has reduce motion turned on, flow pulses are not shown (flowing edges get a solid highlight instead), snapping jumps straight into place without a tween, and the running status dot and dashed outline stay still