Flow Canvas
A node/flow editor surface: your own Razor node cards and SVG edges on a pannable, zoomable canvas, with drag-to-move, snapping, a background grid and zoom controls. A first-party engine — no third-party runtime library.
Installation
dotnet add package Lumeo.Flow
One-time app setup (AddLumeo(), CSS & JS) is covered in the
installation guide.
Usage
@using Lumeo <FlowCanvas />
ParentId,
FlowExtent.Parent, FlowGroupNode, Ctrl+G) — and
virtualization for large graphs (OnlyRenderVisibleNodes) join resizing, helper lines, the
clipboard, loose connections, inline label editing and export. The node, edge and handle shapes you
write stay the same.
When to Use
- Workflow and automation builders — triggers, conditions and actions as cards joined by edges.
- Pipelines, dependency and impact maps where the user arranges the graph by hand.
- Any diagram whose nodes are real Razor content (badges, avatars, buttons), not canvas pixels.
Drag a card to move it (the edges follow live, the position is committed once on drop through
@bind-Nodes). Drag the background to pan, use the wheel to zoom around the pointer,
or focus a card and nudge it with the arrow keys (Shift for 10px).
Readonly keeps pan and zoom but turns every edit off — including the handles, which
drop out of the tab order and go aria-hidden while staying visible as ports (toggle it
below and tab through the canvas). FlowBackground draws
dots, lines or crosses that follow the viewport; FlowControls can sit in any corner.
SnapToGrid snaps drags to SnapGrid (here 20×20, matching the background).
The a-c edge below shows a per-edge colour (Class + a
--lumeo-flow-edge-stroke rule, no !important) and a solid animated edge
(Animated="true" Dashed="false" — a travelling dash overlay, the base line stays solid).
Drag from a source handle (the dot on a card's right edge) to a target handle to propose a
connection — IsValidConnection runs on every attempt. Here a trigger
card may only be a source: connecting into one is rejected. Keyboard: Tab to a source handle,
press Enter to start connecting (every compatible target handle highlights), Tab to
one and press Enter to commit, or Esc to cancel.
Click selects a card, Shift/Ctrl-click toggles more into the selection, and shift-dragging the background draws a marquee. Click an edge to select it too. Delete or Backspace removes whatever is selected — nodes, the edges touching them, and any directly selected edge.
FlowMiniMap shows every node and the current viewport — click or drag it to pan.
FlowPanel is a plain corner overlay for your own content. FlowNodeToolbar
floats above the single selected node and follows it while dragging.
FlowLayout.Tree and FlowLayout.Layered are pure functions: they take
the current nodes and edges and return a new node list with fresh positions — apply it with
Nodes = FlowLayout.Tree(...), then FitViewAsync(). Both break cycles
deterministically, so an accidental loop still lays out instead of hanging, and both handle a
duplicate node id in the input without throwing. On a graph too large to fit above your minimum
zoom, FitViewAsync(new FlowFitViewOptions(MinZoom: 0.6, AnchorNodeId: "start")) centres
on that node at 60% instead of zooming out further to show everything. For a
LeftToRight/TopToBottom tree, centring the anchor wastes half the pane on
the side the tree never grows into — add AnchorAlign: FlowAnchorAlign.Start to pin the
anchor's leading edge (left in LTR, right in RTL; always the top vertically) at the fit padding from
the pane's own edge instead; FlowAnchorAlign.End mirrors it against the trailing edge.
FlowFitViewOptions.AnchorAlign controls WHERE a clamped anchor lands. Center (default) keeps it mid-pane — on this left-to-right tree that wastes half the pane on the side it never grows into. Start pins the anchor's leading edge (left in LTR) at the fit padding instead.
Give the canvas a FlowHistory and it records a snapshot after every committed
change — drag, keyboard move, connect, delete, an applied layout — and handles
Ctrl+Z / Ctrl+Y (or Ctrl+Shift+Z)
while the canvas has focus. FlowControls' ShowHistory adds undo/redo
buttons bound to the same history; ShowSnapToggle adds a grid toggle.
Select an edge (click it) and small grab handles appear at each end — drag one onto a different
handle to move that end, validated through IsValidConnection the same way a fresh
connection is; Esc cancels mid-drag. On a touch screen: one finger pans the
background or drags a node (no long-press — it starts immediately), two fingers pinch-zoom
anchored on the midpoint between them.
Select a card, then drag a grip — corners resize both axes (Shift keeps the card's
aspect ratio), edges resize one. Drag the whole card near another one to see the alignment
guide (HelperLines).
Click a card to focus the canvas, then Ctrl+C / Ctrl+V copies and pastes it (and any edge between two copied cards) at a (20, 20) offset with a new id; Ctrl+D duplicates the current selection directly.
ConnectionMode.Loose lets a drag start on either handle and land on either handle —
try dragging from a target port to another target port. Double-click "next" to edit it inline
(EdgeLabelEditable); Enter commits, Esc cancels.
ToDocument() / LoadDocumentAsync() round-trip the whole canvas as JSON;
ExportSvgAsync() is a pure-vector snapshot; ExportPngAsync() rasterizes
the live node DOM (best effort — it can return null on a tainted canvas).
A node with a ParentId lives inside that group: its X/Y are
relative to the group, so dragging "Checkout" moves everything in it. "Payment" and its cards use
Extent="FlowExtent.Parent" — try dragging them out. Select cards and press
Ctrl+G to group them, Ctrl+Shift+G on a group
to dissolve it. Deleting a group deletes what is inside it.
OnlyRenderVisibleNodes mounts just the nodes inside the viewport plus one viewport of
margin, and follows pans and zooms with hysteresis. Pan around — the minimap still shows all
1,500. (Fit view shows everything at once, so everything mounts: virtualization helps when the
graph is larger than what is on screen.)
API Reference
FlowCanvas
FlowBackground
FlowControls
FlowHandle
FlowMiniMap
FlowPanel
FlowNodeToolbar
With two or more nodes selected, one toolbar renders at the SELECTION'S bounding box top (phase 4) instead of nothing.
FlowNodeResizer
Place inside NodeTemplate, next to FlowHandle.
FlowGroupNode
The default chrome for a group node (label, tinted box, resize grips while selected). Render it from
NodeTemplate for nodes of Type == FlowGroupNode.GroupType ("group"); without a
NodeTemplate the canvas uses it by itself for those nodes.
FlowLayout (static)
Pure functions, not a component — apply the result with Nodes = FlowLayout.Tree(...).
| Member | Description |
|---|---|
| Tree(nodes, edges, options?, measured?) | Rooted tree/forest: a root per node with no incoming edge, children centred under their parent. A node's depth is the LONGEST path reaching it; it is placed under the single parent that explains that depth (the deepest of its parents, when it has more than one) — never under a shallower parent whose subtree happened to reach it first. |
| Layered(nodes, edges, options?, measured?, sweeps = 4) | Longest-path ranking (a node's rank is one more than the deepest predecessor), then barycenter ordering within each rank to reduce crossings. |
| FlowLayoutOptions | Direction (LeftToRight/TopToBottom), NodeSpacing, RankSpacing, GroupPadding (20), GroupHeaderHeight (28). |
| Groups | With nested nodes (ParentId) each group's children are laid out on their own, inside the group (relative, inset by GroupPadding plus GroupHeaderHeight), deepest groups first; a group grows (never shrinks) to fit them; then the top level is laid out with edges that cross a group border counted as edges of that group. |
| measured | A size per node id — pass canvas.MeasuredSizes so a node with no fixed Width/Height lays out at its real rendered size, not the library default. |
| Cycles | Both break cycles first (a deterministic DFS back-edge removal, same input → same result) so a cyclic graph still lays out instead of looping. |
FlowHistory
A plain class, not a component — construct one, pass it as FlowCanvas.History.
| Member | Description |
|---|---|
| FlowHistory(int capacity = 50) | The oldest snapshot is dropped once Capacity is exceeded. |
| Push(nodes, edges) | Records a new current state; discards any redo future. The canvas calls this itself once per committed change. |
| Undo() / Redo() | Returns the Snapshot to apply, or null. The canvas' own UndoAsync/RedoAsync apply it for you. |
| CanUndo / CanRedo / Count / Current | State for your own UI (FlowControls' ShowHistory already wires these two). |
| Changed | Raised after Push/Undo/Redo/Clear. |
Building blocks rendered by FlowCanvas
You never place these yourself; they are listed for completeness.
FlowNode / FlowEdge
| Field | Type | Description |
|---|---|---|
| FlowNode.Id | string | Stable identifier; edges reference nodes by it. |
| FlowNode.X / Y | double | Top-left corner in flow coordinates. |
| FlowNode.Type / Data | string? / object? | Your discriminator and payload for NodeTemplate. |
| FlowNode.Width / Height | double? | Fixed size; when null the node is sized by its content and measured. |
| FlowNode.Draggable | bool | Per-node drag switch (combined with the canvas' NodesDraggable / Readonly). |
| FlowNode.ParentId | string? | The group node this node sits in. Its X/Y are then relative to that group's top-left corner, it paints above the group and moves with it. A missing parent or a parent cycle counts as top-level. |
| FlowNode.Extent | FlowExtent | None (default) or Parent — keeps the node inside its parent's rect while dragged or moved with the arrow keys. |
| FlowEdge.Source / Target | string | Node ids. An edge whose node is missing is skipped. |
| FlowEdge.SourceHandle / TargetHandle | string? | An explicit FlowHandle id. Left null (the default), the edge anchors to the node's default side — right for a source, left for a target — even when the node renders handles; only naming a handle id opts that edge into handle-based anchoring. |
| FlowEdge.Type | FlowEdgeType | Bezier (default), SmoothStep, Step or Straight. |
| FlowNode.Connectable / Deletable | bool | Per-node connect/delete switches (combined with the canvas' own switches). |
| FlowEdge.Label | string? | Renders as a small themed pill at the path midpoint (nowrap, truncates with an ellipsis past max-w-32); use EdgeLabelTemplate for custom content. |
| FlowEdge.Animated / Dashed | bool | Dashed is a static dashed stroke. Animated alone keeps the edge SOLID and adds a small travelling-dash overlay on top (off under prefers-reduced-motion); Animated + Dashed together animate the dash offset of the dashed stroke itself. |
| FlowEdge.MarkerEnd | string? | Any non-null value draws an arrowhead at the target, coloured to match the edge (a dedicated marker for the selected state; the default marker upgrades to context-stroke under @supports in engines that implement it). null draws none. |
| FlowEdge.Deletable | bool | Per-edge delete switch. |
| FlowEdge.Class | string? | Merged onto the edge's <path>. Pair with a rule that sets --lumeo-flow-edge-stroke (e.g. .critical { --lumeo-flow-edge-stroke: var(--color-destructive); }) to recolour this edge — no !important needed, the default stroke reads that same custom property. |
| FlowEdge.Style | string? | Additional inline CSS appended after the library's own — a one-off escape hatch that always wins, including over the selected-state colour. |
Methods (via @ref)
| Method | Effect |
|---|---|
| FitViewAsync(double? padding) | Fits every node into view. |
| FitViewAsync(FlowFitViewOptions) | Fits with per-call Padding/MinZoom/MaxZoom overrides and an optional AnchorNodeId: when the plain fit would need a zoom below MinZoom, places that node at MinZoom instead of zooming out further — "start readable" on a large graph. AnchorAlign (Center default, Start, End) controls WHERE it lands: Center keeps the node mid-pane; Start/End instead pin its leading/trailing edge at the fit padding from the pane's matching edge on both axes — RTL-aware horizontally. Raises OnFitView once applied. |
| GetPaneSizeAsync() | The pane's size measured fresh from the DOM right now (not the last debounced resize report, which can still be in flight after a container resize in the same tick). |
| ZoomInAsync() / ZoomOutAsync() | One ×1.2 step around the pane centre. |
| ZoomToAsync(double zoom) | Zooms to a level (clamped to MinZoom/MaxZoom) around the pane centre. |
| SetCenterAsync(x, y, zoom?) | Centres a flow point. |
| SetViewportAsync(FlowViewport) | Moves the canvas to an exact viewport. |
| ScreenToFlow(x, y) / FlowToScreen(x, y) | Converts between pane-local pixels and flow coordinates. |
| SelectAsync(IEnumerable<string> nodeIds) | Replaces the selection with these node ids (unknown ids are ignored). |
| ClearSelectionAsync() | Clears the node and edge selection. |
| UndoAsync() / RedoAsync() | Applies one step of History; a no-op without a History or with nothing to undo/redo. |
| ToDocument() | The current nodes, edges and viewport as one FlowDocument (phase 4). |
| LoadDocumentAsync(FlowDocument) | Replaces nodes/edges/viewport, clears the selection, pushes one History snapshot (phase 4). |
| ExportSvgAsync() | A self-contained vector SVG (edges + node boxes with their label text) built purely from canvas state — never fails (phase 4). |
| GroupSelectionAsync() | What Ctrl+G does: wraps the selection in a new "group" node (bounds + padding + a label row) in the selection's common parent, re-parents the selected nodes into it at unchanged screen positions and selects it (phase 5). |
| UngroupSelectionAsync() | What Ctrl+Shift+G does: dissolves each selected group (its children move one level up, the group and its own edges are removed); with no group selected, takes the selected nodes out of their group (phase 5). |
| GetAbsolutePosition(string nodeId) | A node's top-left corner in absolute flow coordinates (a child's own X/Y are relative to its group), or null for an unknown id (phase 5). FlowGeometry.GetAbsolutePositions(nodes) does the same for a whole list, without a canvas. |
| ExportPngAsync(double scale = 1) | Best-effort raster of the live node DOM as a data:image/png URL; null on failure (a cross-origin image/font is the usual cause), never throws (phase 4). |
Read-only properties (via @ref)
| Member | Description |
|---|---|
| CurrentNodes / CurrentEdges | The nodes/edges as currently rendered — the bound list, or the canvas' own copy. |
| MeasuredSizes | Every node's measured (width, height) known so far — pass to FlowLayout's measured parameter. |
| CurrentViewport | The viewport as .NET last knows it. |
| PaneSize | The pane's last KNOWN size (from the most recent resize report or engine registration) — (0, 0) before either has happened. For a size guaranteed fresh right now, await GetPaneSizeAsync() instead. |
Keyboard & accessibility
- The canvas is a labelled
role="application"region; every node is a focusablerole="group"in node order. - ← → ↑ ↓ move the focused node by 1px (by one grid step with
SnapToGrid); Shift moves 10×. Each press commits like a drop. - Esc cancels a drag or a connection in progress (the node/line returns) and clears the selection.
- Hold Space to pan by dragging anywhere, including over nodes.
- Click, or Enter/Space on a focused node, selects it and raises
OnNodeClick(Space does not also scroll the page); Shift/Ctrl-click toggles it into a multi-selection; shift-dragging the background draws a marquee. Edges are click-selectable the same way. - Delete/Backspace removes the current selection (configurable via
DeleteKey). - Handles are tab stops when connecting is actually possible (the canvas is not
Readonly/locked,NodesConnectableis on, and the node's ownConnectableis on) — otherwise they drop out of the tab order and arearia-hiddenwhile staying visible as ports. Enter/Space on a source handle enters "connecting" mode (announced to screen readers and marked on every compatible target handle withdata-flow-handle-valid); Enter/Space on a target handle commits it, Esc cancels. - The controls are labelled buttons; the lock and snap-grid toggles report
aria-pressed, undo/redo reportdisabledwhen there is nothing to undo/redo. - Ctrl+Z undoes, Ctrl+Y or Ctrl+Shift+Z redoes, while the canvas has focus and a
Historyis set. - Undo/redo, delete and the arrow-key nudge all skip a keystroke aimed at an editable field — an input, textarea, select, contenteditable region, or anything marked
data-flow-nodrag(e.g. a node card's own inline-edit box, or an inspector panel rendered throughFlowPanel) — so typing there never undoes a graph change or deletes the selected node; the field's own native undo/typing keeps working. Esc is not gated. - Reconnecting an edge (dragging one of a selected edge's grab handles) is pointer/touch only — there is no keyboard equivalent yet; the handles are
aria-hiddenso a screen reader doesn't announce an unreachable control. The edge itself stays fully keyboard-selectable and deletable. - Ctrl+G groups the selection, Ctrl+Shift+G ungroups — both announced to screen readers; a group node's name includes how many nodes it holds ("Payment, 3 nodes") and its role description is "group". Like the other shortcuts they skip a keystroke aimed at an editable field.
- Touch: one finger pans the background or drags a node (starts immediately, no long-press); two fingers pinch-zoom anchored on the midpoint between them.
touch-action: noneon the pane stops the browser from also scrolling/zooming the page.
Styling hooks (data-*)
| Attribute | Element | Meaning |
|---|---|---|
| data-flow-node="id" | Node host | The positioned wrapper around your template (also carries data-type). |
| data-selected / data-dragging | Node host | Presence-only: the node is selected / being dragged right now. |
| data-flow-edge | Edge path | With data-source, data-target, data-edge-type. |
| data-flow-handle | Handle button | With data-handle-type, data-handle-id, data-position. |
| data-flow-nodrag | Anything in a node | Add it to an element inside your template to stop a press there from dragging the node. |
| data-flow-ready="done" | Canvas root | Set by the engine once it has mounted and applied the initial fit. |
| data-flow-handle-valid | Handle button | Presence-only: a structurally compatible target while a connection (pointer or keyboard) is in progress. |
| data-flow-connecting | Handle button | Presence-only: the source handle of an in-progress keyboard connection. |
| data-flow-edge-label | Label div | With data-edge-id; positioned at the path midpoint. |
| data-flow-toolbar-for="id" | FlowNodeToolbar | The node id it floats above; the engine repositions it live while that node is dragged. |
| data-parent-id / data-extent | Node host | A nested node's resolved group id, and "parent" when it is clamped inside it (phase 5). |
| data-flow-group | Node host | Presence-only: the node has children (phase 5). |
| data-flow-virtualized | Canvas root | Presence-only: OnlyRenderVisibleNodes is on (phase 5). |
| data-flow-edge-end | Reconnect grab handle | With data-edge-id and data-end ("source"/"target"); rendered for a selected, reconnectable edge. |
Related Components
- Gantt Chart — when the graph is really a schedule over time.
- Tree View — strictly hierarchical data without free placement.