Interactions
Pan and zoom
Drag the pane to pan. Scroll or pinch to zoom.
Source code
FlowDiagram({
data: { nodes, edges },
viewport: { fitView: true, minZoom: 0.5, maxZoom: 2 },
interaction: {
pane: {
drag: "pan", // or "select"
scroll: "zoom", // or "pan"
doubleClick: "zoom", // or "none"
autoPan: { speed: 15 },
},
},
});Touch
On a touch screen, drag with one finger and pinch with two.
Source code
interaction: {
touch: true,
pane: { pinch: true },
contextMenu: { longPress: 500 }, // ms
}Drag nodes
Drag a node. ⌘-click a second one, then drag both.
Source code
interaction: {
drag: { enabled: true, threshold: 1 },
},
on: {
dragEnd: (_event, node, nodes) => save(nodes),
},
// Keep one node inside its parent
{ id: "a", parentId: "lane", extent: "parent", ... }Select
Shift-drag on the pane to box-select. Press Esc to clear.
Source code
interaction: {
select: { box: true, mode: "partial" },
},
custom: {
selectionBox: ({ rect }) => MySelectionBox(rect),
},
on: {
selectionChange: ({ nodes }) => inspector.show(nodes),
},Hover neighbours
Hover a node. Its neighbours light up.
Source code
custom: {
node: ({ node, isHovered, isNeighbour }) =>
Card(node.data.label, {
highlight: isHovered || isNeighbour,
}),
},Bring to front
Click the card underneath. It rises above the others.
Source code
interaction: {
select: { elevate: true }, // the default
},
// Pin a layer yourself
{ id: "note", zIndex: 10, ... }Connect
Drag from a handle onto another node. Drag an edge end to reconnect.
Source code
interaction: {
connect: {
mode: "strict", // source → target only
radius: 20,
onClick: true, // click two handles
reconnect: true,
validate: (c) => c.source !== c.target,
},
},
on: { connect: (c) => console.log(c) },Drop on the pane
Drag from a handle and release on empty space.
Source code
const flow = new FlowDiagramController();
FlowDiagram({
data: { nodes, edges }, controller: flow,
on: {
connectEnd: ({ state, dropPosition }) => {
if (state.toNode) return;
const id = crypto.randomUUID();
flow.addNodes({ id, position: dropPosition, data: { label: "New" } });
flow.addEdges({ id: `e-${id}`, source: state.fromNode.id, target: id });
},
},
});Context menu
Right-click a node, an edge or the pane.
Source code
interaction: { contextMenu: { enabled: true, longPress: 500 } },
custom: {
contextMenu: ({ target, ids, position }, flow) =>
Menu(position, [
["Duplicate", () => flow.duplicate(ids)],
["Delete", () => flow.deleteElements(ids)],
]),
},Delete
Select a node and press Backspace. Start refuses to go.
Source code
interaction: {
keys: { delete: ["Backspace", "Delete"] }, // null turns it off
},
on: {
// false vetoes; a { nodes, edges } object patches
beforeDelete: ({ nodes }) =>
nodes.every((node) => node.id !== "start"),
},Undo and redo
Move or delete a node, then press ⌘Z. ⇧⌘Z redoes.
Source code
interaction: { history: { enabled: true, limit: 100 } },
on: {
historyChange: ({ canUndo, canRedo }) => toolbar.update(canUndo, canRedo),
},
// From your own buttons
flow.history.undo();
flow.run(() => flow.setNodes(tidy), { history: "merge" });Copy, paste, duplicate
Select a node. Press ⌘C then ⌘V, or ⌘D to duplicate.
Source code
interaction: {
clipboard: { offset: { x: 20, y: 20 }, system: false },
keys: { duplicate: null }, // turn one shortcut off
},
// The same mutations without the keys
const content = flow.getContent(["a", "b"]);
flow.putContent(content, { at: flow.pointerFlowPosition });Keyboard
Click a node, then press the arrow keys. Shift moves ten times as far.
Source code
interaction: {
keys: {
selectAll: "Meta+a",
nudge: ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"],
fitView: "Shift+1",
zoomIn: ["+", "="],
zoomOut: "-",
escape: "Escape",
duplicate: null, // null disables a binding
},
},Groups
Drag the loose step into the stage, then out again.
Source code
const nodes = [
{ id: "stage", type: "group", width: 300, height: 180, position, data: {} },
{ id: "a", parentId: "stage", position: { x: 20, y: 50 }, data },
{ id: "loose", position: { x: 360, y: 60 }, data },
];
interaction: { drag: { dropIntoGroup: true } },
custom: {
node: (args, ctx) => args.node.type === "group"
? Frame({ glow: args.isDropTarget })
: defaultNode(args, ctx),
},Layout
Pick an algorithm. Nodes move into place and stay draggable.
Source code
import { layeredLayout } from "@flitterjs/diagram";
FlowDiagram({
data: { nodes, edges },
layout: {
algorithm: layeredLayout, // tree, force, grid, circular
options: { direction: "LR", rankSpacing: 80 },
runOn: "mount", // or "manual" + flow.layout()
},
});Read-only
Nodes stay put. Pan, zoom, hover and click still work.
Source code
interaction: "viewer",
// Or keep the editor and lock one thing
can: (gesture, target) =>
!(gesture === "drag" && target.id === "locked"),Inline text edit
Double-click a label. Enter commits, Esc cancels.
Source code
interaction: { edit: { text: true } }, // default nodes edit already
custom: {
node: ({ label }) => Card({ child: label }),
},
on: {
editEnd: ({ node, committed }) => committed && save(node),
},Resize
Select the node, then drag a corner or a side.
Source code
interaction: {
edit: { resize: { minWidth: 80, minHeight: 40 } },
},
// Or per node
{ id: "a", resizable: true, resizeOptions: { keepAspectRatio: true }, ... }
on: {
resizeEnd: (_event, node, { width, height }) => save(node.id, width, height),
},Edge waypoints
Click the edge, then drag a dot to bend it. Double-click a bend to remove it.
Source code
interaction: { edit: { waypoints: true } },
edges: [
{ id: "e", source: "a", target: "b", waypoints: [{ x: 200, y: 40 }] },
],
custom: {
edgeHandle: ({ kind, isActive }) =>
Dot({ size: kind === "midpoint" ? 6 : 8, filled: isActive }),
},
on: { reshapeEnd: ({ edge }) => save(edge) },Snap and guides
Drag a node level with another. It snaps and a guide appears.
Source code
interaction: {
drag: {
snap: { grid: [20, 20], guides: true, radius: 5 },
},
},
custom: {
guides: ({ lines }) => GuideLines(lines, { color: "#e11d48" }),
},Overlap policy
Drop a node onto another. It is pushed aside when you let go.
Source code
interaction: {
drag: { overlap: "push" }, // or "reject", default "allow"
},
custom: {
node: ({ node, isOverlapping }) =>
Card(node.data.label, { warn: isOverlapping }),
},
// After a paste or a layout
flow.resolveOverlaps();Collapse and expand
Click the chevron on Lead. The team hides and a count appears.
Source code
{ id: "lead", collapsible: true, position, data }
custom: {
node: ({ node, isCollapsed, hiddenChildCount }, flow) =>
Card(node.data.label, {
badge: isCollapsed ? `+${hiddenChildCount}` : null,
chevron: Chevron({
open: !isCollapsed,
onClick: () => flow.toggleCollapsed(node.id),
}),
}),
},
// The default context menu adds Collapse / Expand tooInsert on edge
Hover the edge and click +. A step lands in between.
Source code
edges: [{ id: "e", source: "a", target: "b", label: "+" }],
custom: {
// The label slot sits on the edge and keeps its hover
edgeLabel: ({ edge, isHovered, x, y }, flow) =>
isHovered
? PlusButton(() =>
flow.insertNodeOnEdge(edge.id, {
id: crypto.randomUUID(),
position: { x: x - 75, y: y - 18 },
data: { label: "New step" },
}))
: SizedBox.shrink(),
},Delete and reconnect
Click Remove review. Draft and Publish stay connected.
Source code
GestureDetector({
onClick: () =>
flow.deleteElements(["review"], { reconnect: true }),
child: Button("Remove review"),
})Align and distribute
Shift-drag around the nodes, then click Align left or Distribute.
Source code
const ids = flow.getSelectedNodes().map((node) => node.id);
flow.alignNodes(ids, "left"); // centerX, right, top, centerY, bottom
flow.distributeNodes(ids, "y"); // or "x"