# ArtisanFlow
> An open-source flowchart ecosystem for Alpine.js and Laravel/Livewire.
> Complete documentation for AlpineFlow (npm) and WireFlow (Composer).
---
# AlpineFlow Documentation
# Installation
## npm (recommended)
```bash
npm install @getartisanflow/alpineflow alpinejs
```
Register AlpineFlow as an Alpine plugin:
```js
import Alpine from 'alpinejs';
import AlpineFlow from '@getartisanflow/alpineflow';
Alpine.plugin(AlpineFlow);
Alpine.start();
```
> **Livewire users:** Alpine is already bundled with Livewire. Don't import Alpine separately — just register the plugin on the `alpine:init` event. See [Livewire integration](#livewire).
### CSS
AlpineFlow requires structural CSS. A default theme is optional but recommended:
```css
/* Both structural + default theme */
@import '@getartisanflow/alpineflow/css';
@import '@getartisanflow/alpineflow/theme';
```
Or import structural CSS only and bring your own theme:
```css
@import '@getartisanflow/alpineflow/css';
```
See [Theming](../theming/_index.md) for CSS variable customization.
## CDN
For prototyping or non-bundler setups:
```html
```
The CDN build auto-registers with Alpine via the `alpine:init` event. No manual plugin registration needed.
## Livewire
If you're using [WireFlow](https://github.com/getartisanflow/wireflow) (the Livewire integration), AlpineFlow is bundled automatically — no npm install required. See the [WireFlow docs](https://github.com/getartisanflow/wireflow) for setup.
If you're using AlpineFlow directly in a Livewire app (without WireFlow), register it on the `alpine:init` event since Livewire manages Alpine's lifecycle:
```js
import AlpineFlow from '@getartisanflow/alpineflow';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
});
```
> **Do not** call `Alpine.start()` — Livewire handles that.
## Optional Addons
AlpineFlow's core is lightweight. Layout engines, collaboration, and whiteboard tools are separate sub-path imports that require their own peer dependencies:
| Addon | Import | Peer Dependency |
|-------|--------|-----------------|
| Dagre layout | `@getartisanflow/alpineflow/dagre` | `@dagrejs/dagre` |
| Force layout | `@getartisanflow/alpineflow/force` | `d3-force` |
| Tree layout | `@getartisanflow/alpineflow/hierarchy` | `d3-hierarchy` |
| ELK layout | `@getartisanflow/alpineflow/elk` | `elkjs` |
| Whiteboard | `@getartisanflow/alpineflow/whiteboard` | — |
| Schema designer | `@getartisanflow/alpineflow/schema` | — |
| Collaboration | `@getartisanflow/alpineflow/collab` | `yjs`, `y-websocket`, `y-protocols` |
Example — adding dagre layout:
```bash
npm install @dagrejs/dagre
```
```js
import AlpineFlow from '@getartisanflow/alpineflow';
import AlpineFlowDagre from '@getartisanflow/alpineflow/dagre';
Alpine.plugin(AlpineFlow);
Alpine.plugin(AlpineFlowDagre);
```
See [Addons](../addons/_index.md) for detailed setup per addon.
## Requirements
- Alpine.js 3.15+
- Modern browser (ES2020+)
# First Flow
This tutorial walks you through building your first flow diagram, starting with the bare minimum and progressively layering on features.
## Minimal example
After [installing](installation.md) AlpineFlow, this is all you need to render an interactive flow:
```html
```
This gives you:
- Two draggable nodes connected by an edge
- Pan and zoom (mouse drag + scroll wheel)
- Connection handles for creating new edges (drag from a handle)
::demo
```html
```
::enddemo
### What's happening
| Element | Purpose |
|---------|---------|
| `flowCanvas({...})` | Alpine data component — manages all state (nodes, edges, viewport) |
| `.flow-container` | CSS class that sets up the canvas dimensions and overflow |
| `x-flow-viewport` | The pannable/zoomable viewport layer |
| `x-flow-node="node"` | Renders a node and makes it draggable |
| `x-flow-handle:target` | Connection handle that accepts incoming edges |
| `x-flow-handle:source` | Connection handle that starts outgoing edges |
Edges are rendered automatically from the `edges` array — you don't write edge markup.
## Adding features
Enable common features by passing config options to `flowCanvas()`:
```html
```
Each option is independent — use only what you need. See [Configuration](../configuration/index.md) for the full list.
::demo
```html
```
::enddemo
## Custom node content
Nodes can contain any HTML. Use Alpine directives for dynamic content:
```html
```
- `x-flow-drag-handle` restricts dragging to the title bar instead of the entire node
- Handle position modifiers (`.left`, `.right`, `.top`, `.bottom`) control placement
See [Nodes](../nodes/basics.md) and [Handles](../handles/_index.md) for full details.
::demo
```html
```
::enddemo
## Styling
All visual properties are controlled via CSS variables. Override them on `.flow-container`:
```css
.flow-container {
--flow-node-bg: #1e293b;
--flow-node-border: 1px solid #334155;
--flow-node-color: #e2e8f0;
--flow-edge-stroke: #475569;
}
```
See [Theming](../theming/_index.md) for the complete variable reference.
## Next steps
- [Core Concepts](concepts.md) — how directives, viewport, and reactive data work
- [Configuration](../configuration/index.md) — all `flowCanvas()` options
- [Directives](../nodes/basics.md) — node, handle, toolbar, and more
- [Edge Types](../edges/types.md) — straight, smoothstep, bezier, and custom edges
- [Connections](../connections/_index.md) — drag-connect, validation, click-to-connect
- [$flow Magic](../api/flow-magic/index.md) — programmatic control via `$flow`
# Core Concepts
AlpineFlow builds on Alpine.js to turn declarative HTML into interactive flow diagrams. Understanding these six concepts will help you work with every part of the library.
## The flow container
Every flow starts with a container element that has two things: the `flow-container` CSS class and an `x-data="flowCanvas({...})"` attribute.
```html
```
Both are required for different reasons:
- **`x-data="flowCanvas({...})"`** registers the Alpine data component that manages all flow state — nodes, edges, viewport position, selection, history, and more. This is where you pass your initial configuration.
- **`class="flow-container"`** applies the structural CSS that sets up positioning, overflow clipping, and the coordinate system the viewport operates within. Without it, nodes won't position correctly and pan/zoom won't work.
The container must have an explicit height (via `style`, a CSS class, or a parent layout) since flow diagrams don't have intrinsic dimensions.
## Directives
AlpineFlow extends Alpine with `x-flow-*` directives that attach flow behavior to HTML elements. The naming convention follows a consistent pattern:
- **`x-flow-{feature}`** — the base directive, e.g. `x-flow-viewport`, `x-flow-node`, `x-flow-handle`
- **Arguments via colon** — pass a role or type after a colon, e.g. `x-flow-handle:source` (a source handle) or `x-flow-handle:target` (a target handle)
- **Modifiers via dot** — append behavior modifiers with dots, e.g. `x-flow-handle:source.right` (a source handle positioned on the right edge) or `x-flow-collapse.group` (collapse with group semantics)
This mirrors Alpine's own syntax (`x-on:click.prevent`, `x-bind:class`), so if you know Alpine, the pattern is familiar.
Some commonly used directives:
| Directive | Purpose |
|-----------|---------|
| `x-flow-viewport` | Wraps the pannable/zoomable layer |
| `x-flow-node="node"` | Makes an element a positioned, draggable node |
| `x-flow-handle:source` | Adds a connection handle for outgoing edges |
| `x-flow-handle:target` | Adds a connection handle for incoming edges |
| `x-flow-drag-handle` | Restricts node dragging to a specific child element |
| `x-flow-action:fitView` | Binds a button click to a flow action |
See [Nodes](../nodes/basics.md) for the full reference.
## The viewport
The `x-flow-viewport` directive creates the pannable and zoomable layer. All nodes must be placed inside it:
```html
```
The viewport translates mouse drags into panning and scroll wheel events into zooming. It applies a CSS transform to move and scale all child content together. Elements placed outside the viewport (like toolbars or overlays) stay fixed relative to the container.
Edges are **not** placed inside the viewport manually. AlpineFlow renders an SVG edge layer automatically based on the `edges` array — you never write edge markup.
## Reactive data
The `nodes` and `edges` arrays you pass to `flowCanvas()` become reactive Alpine data. Mutating them updates the UI immediately:
```js
// Adding a node at runtime
$flow.addNodes({ id: 'c', position: { x: 100, y: 200 }, data: { label: 'New' } });
// Removing an edge
edges = edges.filter(e => e.id !== 'e1');
// Updating a node's data
nodes.find(n => n.id === 'a').data.label = 'Updated';
```
This works because Alpine's reactivity system tracks property access and triggers re-renders when values change. There is no separate "setState" or "dispatch" step — direct mutation is the intended pattern.
Key points:
- **Nodes** require `id`, `position: { x, y }`, and `data` (an object for your custom properties)
- **Edges** require `id`, `source` (node ID), and `target` (node ID)
- Edges are rendered automatically from the array — no edge templates or markup needed
- Adding, removing, or modifying items in either array triggers a UI update
For bulk additions or batched state changes, wrap them in [`$flow.batch(fn)`](../api/flow-magic/nodes.md#batch) so AlpineFlow runs a single reconciliation after your callback instead of one per mutation.
## The $flow magic
AlpineFlow registers a `$flow` magic property (available via Alpine's magic system) that gives you programmatic access to the canvas from any expression inside the flow container:
```html
```
Common `$flow` methods include:
| Method | Description |
|--------|-------------|
| `fitView(options?)` | Pan and zoom to fit all (or selected) nodes in view |
| `addNodes(node \| nodes[])` | Add one or more nodes to the canvas |
| `removeNodes(ids[])` | Remove nodes (and their connected edges) |
| `setViewport(viewport, options?)` | Set pan and zoom level |
| `animate(targets, options?)` | Smoothly transition node, edge, or viewport properties |
| `getNode(id)` | Retrieve a node by ID |
| `toObject()` | Export the full flow state as a serializable object |
See [$flow Magic](../api/flow-magic/index.md) for the complete API.
## Scope rules
Alpine evaluates attributes in the scope of the nearest `x-data` ancestor. This creates an important subtlety: **directives placed on the same element as `x-data` evaluate in that element's own scope, not a parent's.**
This means that on the `flowCanvas` element itself, you cannot reference variables from a parent `x-data`:
```html
...
```
If you need parent data accessible inside the flow container (common in WireFlow/Livewire setups), use `Object.assign($data, ...)` to merge it into the flow's scope, or restructure so the parent data lives on the same element:
```html
...
```
This is particularly relevant when using WireFlow, where Livewire's `wire:model` bindings need to coexist with the `flowCanvas` data scope.
::demo
```html
```
::enddemo
# Node Basics
Nodes are the primary building blocks of an AlpineFlow diagram. The `x-flow-node` directive binds a DOM element as a flow node, handling positioning, dragging, selection, and all visual state.
Drag the nodes to reposition them — edges follow automatically:
::demo
```html
```
::enddemo
## Usage
Apply `x-flow-node` to any element inside a flow canvas. The expression must evaluate to a [FlowNode](#node-data-shape) object:
```html
```
## What it does
- Positions the element absolutely using `node.position`
- Makes the node draggable via pointer events (d3-drag)
- Selects on click (Shift+click for multi-select — on touch devices, two-finger tap enters selection mode — see [Touch & Mobile](../interaction/touch.md))
- Applies CSS classes reactively: `.flow-node`, `.flow-node-selected`, `.flow-node-locked`, `.flow-node-dragging` (during drag), `.flow-node-{runState}` (when `runState` is set), custom `node.class`
- Applies `data-flow-node-type` attribute reflecting `node.type` (or `'default'`), useful for CSS selectors like `[data-flow-node-type="condition"]`
- Applies inline styles from `node.style`
- Applies dimensions from `node.dimensions` — inline `style.height` is only set when the node is a container (has `childLayout`), is a parent of other nodes (some other node references it via `parentId`), or has `fixedDimensions: true`; plain leaf nodes let content determine height and the ResizeObserver captures the natural height back into `node.dimensions`
- Respects per-node flags: `draggable`, `selectable`, `deletable`, `connectable`, `locked`, `hidden`
## Node data shape
Every node is a plain object with the following properties:
```js
{
id: 'node-1', // Required. Unique string ID.
position: { x: 100, y: 200 }, // Required. Flow-space coordinates.
data: { label: 'My Node' }, // Optional. Arbitrary data for templates.
type: 'default', // Optional. Maps to nodeTypes registry.
class: 'my-class', // Optional. CSS class(es) added to the node element.
style: 'background: red', // Optional. Inline styles or style object.
dimensions: { width: 200, height: 80 }, // Optional. Explicit dimensions.
fixedDimensions: false, // Optional. Opt-in to inline style.height; ResizeObserver skips node.
resizeObserver: true, // Optional. false excludes node from the shared ResizeObserver.
minDimensions: { width: 100 }, // Optional. Lower bound applied by observer (Partial).
maxDimensions: { width: 800 }, // Optional. Upper bound applied by observer (Partial).
selected: false, // Optional. Selection state.
draggable: true, // Optional. Per-node drag override.
selectable: true, // Optional. Per-node selection override.
connectable: true, // Optional. Per-node connection override.
deletable: true, // Optional. Per-node delete override.
hidden: false, // Optional. Hide from rendering.
locked: false, // Optional. Fully freeze all interactions.
resizable: true, // Optional. Per-node resize override (requires x-flow-resizer).
parentId: 'group-1', // Optional. Makes this a child of another node.
expandParent: false, // Optional. Grow parent when child reaches edge.
zIndex: 0, // Optional. Explicit z-index.
sourcePosition: 'bottom', // Optional. Default handle position for sources.
targetPosition: 'top', // Optional. Default handle position for targets.
shape: 'diamond', // Optional. Node shape variant.
rotation: 0, // Optional. Rotation angle in degrees.
nodeOrigin: [0, 0], // Optional. Per-node anchor point override.
}
```
Only `id` and `position` are required. Everything else has sensible defaults.
## Per-node flags
Override global behavior on individual nodes by setting these flags:
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `draggable` | `boolean` | `true` | Can be dragged |
| `selectable` | `boolean` | `true` | Can be selected |
| `connectable` | `boolean` | `true` | Handles accept connections |
| `deletable` | `boolean` | `true` | Can be deleted via keyboard (desktop only — provide a delete button for touch users) |
| `locked` | `boolean` | `false` | Fully freeze -- no drag, delete, connect, select, or resize. Shows dashed border. Individual flags override when set explicitly. |
| `hidden` | `boolean` | `false` | Hidden from rendering |
| `resizable` | `boolean` | -- | Per-node resize override (requires `x-flow-resizer`) |
> **Note:** Setting `locked: true` freezes all interactions at once. If you need to lock a node but still allow selection, set `locked: true` and `selectable: true` -- explicit flags take precedence over the lock.
Try interacting with each node — the locked node has a dashed border, the non-draggable node can't be moved:
::demo
```html
```
::enddemo
## CSS classes
| Class | Applied when |
|-------|-------------|
| `.flow-node` | Always |
| `.flow-node-selected` | Node is selected |
| `.flow-node-locked` | `node.locked` is true |
| `.flow-node-group` | `node.type` is `'group'` |
| `.flow-node-hidden` | `node.hidden` is true |
| `.flow-node-{shape}` | Node has a shape (e.g., `.flow-node-diamond`) |
## Custom node content
Nodes can contain any HTML. Use the `nodrag` CSS class on interactive elements (buttons, inputs, sliders) to prevent them from triggering a node drag:
```html
```
::demo
```html
```
::enddemo
## Node types
Register custom templates per node type using `nodeTypes` in your canvas configuration. You can reference a `` element by CSS selector or provide a render function.
### Template selector
```html
```
### Render function
```js
nodeTypes: {
'custom': (node, el) => {
el.textContent = node.data.label;
},
}
```
Each type gets its own template — nodes with `type: 'action'` use the action template automatically:
::demo
```html
```
::enddemo
## Programmatic node management
Add, remove, and query nodes via the `$flow` magic. See the [Nodes API reference](../api/flow-magic/nodes.md) for all available methods.
::demo
```toolbar
```
```html
```
::enddemo
## See also
- [Drag Handles](drag-handles.md) -- restrict drag to a specific element
- [Shapes](shapes.md) -- built-in and custom node shapes
- [Groups](groups.md) -- parent-child hierarchies and nesting
- [Resize](resize.md) -- add resize handles to nodes
- [Rotation](rotation.md) -- add rotation handles to nodes
- [Styling](styling.md) -- CSS classes, status colors, and theming
# Drag Handles
The `x-flow-drag-handle` directive restricts node dragging to a specific handle element rather than allowing the entire node surface to initiate a drag. This is useful for nodes with interactive content like buttons, inputs, or text areas that should not trigger dragging.
The first node drags from anywhere. The second only from its header. The third only from the grip icon:
::demo
```html
Body content
```
::enddemo
## Usage
Place `x-flow-drag-handle` on any element inside an `x-flow-node`:
```html
Title
Content that does NOT start a drag.
```
Only a `pointerdown` event originating from within the `x-flow-drag-handle` element will start a drag operation. Pointer events on the rest of the node are ignored for dragging purposes.
## Signature
| Part | Value |
|------|-------|
| Expression | None |
| Modifiers | None |
## Behavior
- **Must be placed inside an `x-flow-node` element.** The directive has no effect outside a node.
- The directive automatically adds a CSS class and `data-*` attributes to the handle element for styling and internal identification.
- The parent `x-flow-node` element receives the `.flow-node-has-handle` CSS class, which you can target to adjust cursor or visual styles on the node body.
## Styling
When a drag handle is present, you typically want to show a grab cursor only on the handle and a default cursor on the rest of the node:
```css
.flow-node-has-handle {
cursor: default;
}
[x-flow-drag-handle] {
cursor: grab;
}
[x-flow-drag-handle]:active {
cursor: grabbing;
}
```
The default theme provides built-in styles for drag handles using the following CSS variables:
| Variable | Description |
|----------|-------------|
| `--flow-drag-handle-bg` | Background color of the drag handle |
| `--flow-drag-handle-border-bottom` | Bottom border separating handle from body |
| `--flow-drag-handle-color` | Text color within the drag handle |
| `--flow-drag-handle-padding` | Padding inside the drag handle |
| `--flow-drag-handle-border-radius` | Border radius of the drag handle |
## See also
- [Node Basics](basics.md) -- core node directive and configuration
- [Styling](styling.md) -- CSS classes and theming
# Shapes
AlpineFlow ships with seven built-in node shapes and supports registering custom shapes. Shapes control the visual outline, clip-path, and handle positioning of a node.
**Rectangle** (default), **circle**, and **stadium** use border-radius:
::demo
```html
```
::enddemo
## Built-in shapes
Set the `shape` property on a node to apply a shape:
```js
{ id: 'decision', position: { x: 100, y: 100 }, shape: 'diamond', data: { label: 'Yes/No?' } }
```
| Shape | CSS class | Technique |
|-------|-----------|-----------|
| `diamond` | `.flow-node-diamond` | `clip-path` (rotated square) |
| `hexagon` | `.flow-node-hexagon` | `clip-path` (6-point polygon) |
| `parallelogram` | `.flow-node-parallelogram` | `clip-path` (skewed rectangle) |
| `triangle` | `.flow-node-triangle` | `clip-path` (3-point polygon) |
| `circle` | `.flow-node-circle` | `border-radius: 50%` |
| `cylinder` | `.flow-node-cylinder` | `clip-path` (rounded rectangle with elliptical caps) |
| `stadium` | `.flow-node-stadium` | `border-radius: 9999px` |
Nodes without a `shape` property render as standard rectangles.
## Shape rendering
Clipped shapes (diamond, hexagon, parallelogram, triangle, cylinder) use a **background-as-border** pattern. The outer element's background acts as the border color, while a `::after` pseudo-element provides the inner fill with a slightly inset clip-path.
::demo
```html
```
::enddemo
When a node is selected, the outer background changes to the accent color, giving the appearance of a colored border following the shape outline:
```css
.flow-node-diamond {
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
background: var(--flow-shape-border-color);
}
.flow-node-diamond::after {
clip-path: polygon(50% 2%, 98% 50%, 50% 98%, 2% 50%);
background: var(--flow-node-bg);
}
```
The inset is 2px by default (3px when selected) to create the border appearance.
## Handle positions
Each shape has **perimeter-aware handle positions** defined in CSS. Handles snap to the actual shape edge rather than the rectangular bounding box. For example, a diamond's left handle sits at the leftmost point of the diamond (the midpoint of the left edge), not at the top-left corner of the bounding box.
Handle positions are computed to match the `perimeterPoint()` values in `shapes.ts`, ensuring that edges connect visually to the shape outline.
## Custom shapes
Register custom shapes via `config.shapeTypes`. Each shape definition requires a `perimeterPoint` function and an optional `clipPath` CSS string:
```js
shapeTypes: {
octagon: {
perimeterPoint(width, height, position) {
// Return { x, y } coordinates on the octagon perimeter
// for the given handle position ('top', 'right', 'bottom', 'left')
const inset = Math.min(width, height) * 0.3;
// ... compute point based on position
return { x, y };
},
clipPath: 'polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%)',
},
}
```
Then apply it to nodes:
```js
{ id: 'n1', shape: 'octagon', position: { x: 0, y: 0 }, data: { label: 'Custom' } }
```
The node will receive the CSS class `.flow-node-octagon`. Add corresponding styles in your CSS for the background-as-border pattern and handle positioning.
## Shape CSS variable
| Variable | Description |
|----------|-------------|
| `--flow-shape-border-color` | Border fill color for clipped shapes |
Override this variable to change the border appearance of all shaped nodes:
```css
.flow-container {
--flow-shape-border-color: #64748b;
}
```
## See also
- [Node Basics](basics.md) -- node directive and data shape
- [Styling](styling.md) -- CSS classes and theming
# Groups & Nesting
AlpineFlow supports hierarchical node structures where parent nodes contain and manage child nodes. Groups enable visual organization, drag-to-reparent interactions, and validation rules for child membership.
Drag the children within the group — they expand the parent when near the boundary:
::demo
```html
```
::enddemo
## Parent-child basics
To create a parent-child relationship, set `parentId` on the child node and `type: 'group'` on the parent node. Child node positions are **relative to their parent**, so `{ x: 10, y: 20 }` means 10px right and 20px down from the parent's top-left corner.
```js
nodes: [
{ id: 'group-1', type: 'group', position: { x: 100, y: 100 }, data: { label: 'My Group' }, width: 300, height: 200 },
{ id: 'child-1', parentId: 'group-1', position: { x: 10, y: 40 }, data: { label: 'Child Node' } },
{ id: 'child-2', parentId: 'group-1', position: { x: 10, y: 100 }, data: { label: 'Another Child' } },
]
```
Set `expandParent: true` on a child node to automatically expand the parent's dimensions when the child is dragged near the parent's boundary.
## Group styling
Group nodes receive the `.flow-node-group` CSS class, which applies a dashed border and semi-transparent background by default. The group label is rendered from `data.label` and appears at the top of the group.
```css
.flow-node-group {
border: 2px dashed var(--flow-group-border-color);
background: var(--flow-group-bg);
}
```
You can override these styles in your own CSS to customize group appearance.
## Nested groups
Multi-level nesting is fully supported. A group can be the child of another group, creating deep hierarchies. Z-index is auto-computed as `parent.zIndex + 1 + child.zIndex`, ensuring children always render above their parents without manual z-index management.
```js
nodes: [
{ id: 'outer', type: 'group', position: { x: 0, y: 0 } },
{ id: 'inner', type: 'group', parentId: 'outer', position: { x: 20, y: 40 } },
{ id: 'leaf', parentId: 'inner', position: { x: 10, y: 30 }, data: { label: 'Deep child' } },
]
```
## Drag-to-reparent
Nodes can be reparented by dragging them onto a droppable target. To make a node accept dropped children, set `droppable: true` on the target node.
```js
{ id: 'target', type: 'group', droppable: true, position: { x: 200, y: 200 }, data: { label: 'Drop here' } }
```
When a node is dragged onto a droppable node:
1. The node is **auto-detached** from its old parent (if any).
2. Its position is recalculated relative to the new parent.
3. A **circular guard** prevents reparenting a node to one of its own descendants.
You can also reparent programmatically:
```js
$flow.reparentNode('child-id', 'new-parent-id')
```
Drag the loose node onto either group to reparent it:
::demo
```html
```
::enddemo
### Constraining children to parent
Set `extent: 'parent'` on a child node to prevent it from being dragged outside its parent's bounds:
```js
{ id: 'child-1', parentId: 'group-1', extent: 'parent', position: { x: 10, y: 40 }, data: { label: 'Locked in' } }
```
The children below can't leave their group:
::demo
```html
```
::enddemo
## Child validation
Define validation rules per node type using `childValidationRules` in your config:
| Property | Type | Description |
|----------|------|-------------|
| `allowedTypes` | `string[]` | Node types permitted as children |
| `minChildren` | `number` | Minimum number of children required |
| `maxChildren` | `number` | Maximum number of children allowed |
| `maxDepth` | `number` | Maximum nesting depth from this node |
| `custom` | `(parent, child) => boolean` | Custom validator function |
```js
childValidationRules: {
group: {
allowedTypes: ['task', 'note'],
maxChildren: 10,
maxDepth: 3,
custom: (parent, child) => child.data.priority !== 'low',
},
}
```
Validation runs on drag, delete, and programmatic add. Invalid states apply CSS classes for visual feedback:
- `.flow-node-invalid` on nodes that fail validation
- `.flow-node-drop-target` on the target during a valid drag-over
Use the `onChildValidationFail` callback to handle failures:
```js
onChildValidationFail: (parent, child, reason) => {
console.warn(`Cannot add ${child.id} to ${parent.id}: ${reason}`)
}
```
## Child layout
AlpineFlow provides methods for automatic child layout within groups:
- **`layoutChildren(parentId)`** -- auto-stacks children vertically within the parent, evenly spaced.
- **`propagateLayoutUp()`** -- recalculates parent dimensions bottom-up after layout changes.
- **`reorderChild(nodeId, newOrder)`** -- moves a child to a new position in the visual order.
```js
$flow.layoutChildren('group-1')
$flow.reorderChild('child-2', 0) // Move to first position
```
**Automatic re-layout (v0.2.1-alpha):** You rarely need to call `layoutChildren` manually. Three things trigger it automatically:
- Mutating any `node.childLayout` property — `columns`, `gap`, `padding`, `headerHeight`, `direction`, or `stretch` — triggers `layoutChildren` on that parent immediately.
- Calling `addNodes` with a `parentId` triggers `layoutChildren` on the affected parent, consistent with how `removeNodes` already behaves.
- When a child's rendered dimensions change (content reflow, template expansion, etc.), the shared ResizeObserver updates `node.dimensions` and schedules `layoutChildren` on the parent.
Manual `$flow.layoutChildren(parentId)` calls remain useful when you reposition children by directly mutating `node.position` (rather than going through `addNodes`), or when you need `{ shallow: true }` to skip recursive sub-layout.
Click "Layout" to auto-stack children within the group:
::demo
```toolbar
```
```html
```
::enddemo
## Collapse and expand
The `x-flow-collapse` directive toggles the collapsed state of a node, hiding its descendant nodes and any edges connected to them.
```html
```
### Collapse modifiers
| Modifier | Description |
|----------|-------------|
| `.instant` | Skip the collapse/expand animation |
| `.all` | Collapse or expand all collapsible nodes at once |
| `.expand` | Expand only -- never collapse. Useful for "expand all" buttons |
| `.children` | Collapse/expand the children of the specified node, not the node itself |
Modifiers can be combined:
```html
```
### Collapse events
| Event | Payload | Description |
|-------|---------|-------------|
| `node-collapse` | `{ node, nodeId }` | Fired when a node is collapsed |
| `node-expand` | `{ node, nodeId }` | Fired when a node is expanded |
Click the collapse button to hide the group's children:
::demo
```toolbar
```
```html
```
::enddemo
## Condense
The `x-flow-condense` directive toggles a node between its full view and a condensed (summary) view. Unlike collapse, which hides children, condense changes how the node itself is displayed.
```html
```
Use `x-show` to switch between full and condensed content:
```html
```
Condensed nodes receive the `.flow-node-condensed` CSS class, which applies `overflow: hidden` by default. Style the condensed state with CSS:
```css
.flow-node-condensed {
max-height: 48px;
transition: max-height 0.2s ease;
}
```
### Condense events
| Event | Payload | Description |
|-------|---------|-------------|
| `node-condense` | `{ node, nodeId }` | Fired when a node is condensed |
| `node-uncondense` | `{ node, nodeId }` | Fired when a node is restored to full view |
## See also
- [Node Basics](basics.md) -- core node directive and data shape
- [Styling](styling.md) -- CSS classes and theming
# Resizable Nodes
The `x-flow-resizer` directive adds interactive resize handles to a flow node, allowing users to drag edges and corners to change the node's dimensions.
::demo
```html
```
::enddemo
## Usage
Place the directive inside an element with `x-flow-node`:
```html
```
## Constraints
Pass an optional constraints object as the directive expression:
```html
```
| Property | Type | Description |
|----------|------|-------------|
| `minWidth` | `number` | Minimum width in pixels |
| `maxWidth` | `number` | Maximum width in pixels |
| `minHeight` | `number` | Minimum height in pixels |
| `maxHeight` | `number` | Maximum height in pixels |
If no expression is provided, the node can be resized without constraints.
Try resizing — the left node is constrained, the right node resizes freely:
::demo
```html
```
::enddemo
## Modifiers
Control which resize handles are rendered:
| Modifier | Description |
|----------|-------------|
| _(none)_ | All 8 handles (4 corners + 4 edges) |
| `.corners` | 4 corner handles only |
| `.edges` | 4 edge handles only |
| `.bottom` | Bottom edge handle only |
| `.right` | Right edge handle only |
| `.top` | Top edge handle only |
| `.left` | Left edge handle only |
Modifiers can be combined to show specific handles:
```html
```
::demo
```html
```
::enddemo
## Behavior
- Respects the per-node `resizable` flag. If `node.resizable` is `false`, the handles will not appear.
- Respects grid snapping when enabled in the canvas configuration. Resized dimensions snap to the grid increment.
- During a resize, `node.dimensions.width` and `node.dimensions.height` are updated in real time as the user drags.
- Resize-start automatically sets `fixedDimensions: true` on the node so the explicit dimension persists after the ResizeObserver measures it. If you later reset `fixedDimensions` to `false`, the node returns to content-driven sizing and the ResizeObserver takes over again.
The first node snaps to a 20px grid when resized. The second has `resizable: false` — no handles appear:
::demo
```html
```
::enddemo
## Events
The following events are dispatched during a resize operation:
| Event | Payload | Description |
|-------|---------|-------------|
| `node-resize-start` | `{ node, handle, initialWidth, initialHeight }` | Fired when resizing begins |
| `node-resize` | `{ node, handle, width, height }` | Fired continuously on resize |
| `node-resize-end` | `{ node, handle, width, height }` | Fired when resizing completes |
## See also
- [Node Basics](basics.md) -- per-node flags including `resizable`
- [Rotation](rotation.md) -- add rotation handles
# Rotatable Nodes
The `x-flow-rotate` directive adds a rotation handle to a flow node, allowing users to rotate the node by dragging the handle around the node's center.
::demo
```html
```
::enddemo
## Usage
Place the directive inside an element with `x-flow-node`:
```html
```
## Snap modifier
Use the `.snap` modifier to constrain rotation to fixed increments. The default snap increment is 15 degrees, or pass a custom value as the expression:
```html
```
## Behavior
- Updates `node.rotation` in degrees (0--359).
- The rotation is calculated relative to the node's center point.
- The handle is rendered outside the node boundary, offset by `--flow-rotate-handle-offset`.
## CSS variables
Customize the rotation handle appearance:
| Variable | Default | Description |
|----------|---------|-------------|
| `--flow-rotate-handle-bg` | `#fff` | Background color of the rotation handle |
| `--flow-rotate-handle-border` | `#6b7280` | Border color of the rotation handle |
| `--flow-rotate-handle-size` | `20px` | Width and height of the rotation handle |
| `--flow-rotate-handle-offset` | `30px` | Distance from the node edge to the handle |
```css
.flow-canvas {
--flow-rotate-handle-bg: #e0f2fe;
--flow-rotate-handle-border: #0284c7;
--flow-rotate-handle-size: 16px;
--flow-rotate-handle-offset: 24px;
}
```
## See also
- [Node Basics](basics.md) -- core node directive and rotation property
- [Resize](resize.md) -- add resize handles
- [Styling](styling.md) -- CSS variables and theming
# Node Styling
AlpineFlow provides a comprehensive set of CSS classes and variables for styling nodes. You can apply per-node classes and inline styles, use built-in status classes for visual states, and customize the appearance through CSS variables.
::demo
```html
```
::enddemo
## Node CSS classes
These classes are applied automatically based on node state:
| Class | Applied when |
|-------|-------------|
| `.flow-node` | Always present on every node |
| `.flow-node-selected` | Node is currently selected |
| `.flow-node-locked` | `node.locked` is `true` (dashed border) |
| `.flow-node-group` | `node.type` is `'group'` |
| `.flow-node-hidden` | `node.hidden` is `true` (display: none) |
| `.flow-node-has-handle` | Node contains an `x-flow-drag-handle` |
| `.flow-node-condensed` | Node is in condensed view |
| `.flow-node-invalid` | Node fails child validation |
| `.flow-node-drop-target` | Node is a valid drop target during drag |
| `.flow-node-annotation` | Whiteboard annotation node (no background/border) |
| `.flow-node-{shape}` | Node has a shape (e.g., `.flow-node-diamond`, `.flow-node-circle`) |
## Status classes
Apply status classes to nodes for semantic color coding. Set them via the `class` property on a node:
```js
{ id: '1', position: { x: 0, y: 0 }, class: 'flow-node-success', data: { label: 'Completed' } }
```
| Class | Purpose | Suggested color |
|-------|---------|-----------------|
| `.flow-node-success` | Completed or approved states | Green |
| `.flow-node-warning` | Needs attention or pending review | Amber/Yellow |
| `.flow-node-danger` | Error or failed states | Red |
| `.flow-node-info` | Informational or neutral highlights | Blue |
| `.flow-node-primary` | Primary or active emphasis | Brand/Accent |
Style these classes in your theme CSS to match your design system:
```css
.flow-node-success { border-color: #22c55e; border-top-color: #22c55e; }
.flow-node-warning { border-color: #f59e0b; border-top-color: #f59e0b; }
.flow-node-danger { border-color: #ef4444; border-top-color: #ef4444; }
.flow-node-info { border-color: #3b82f6; border-top-color: #3b82f6; }
.flow-node-primary { border-color: #8b5cf6; border-top-color: #8b5cf6; }
```
## Custom CSS via node properties
### Classes
Set `class` on a node to add custom CSS classes:
```js
{ id: '1', position: { x: 0, y: 0 }, class: 'my-custom-node highlight', data: { label: 'Styled' } }
```
Multiple classes can be space-separated. These are applied in addition to the automatic `.flow-node` class.
### Inline styles
Set `style` on a node for inline styles:
```js
{ id: '1', position: { x: 0, y: 0 }, style: 'background: #fef3c7; border-color: #f59e0b;', data: { label: 'Custom' } }
```
Both nodes use inline `style` for custom appearance:
::demo
```html
```
::enddemo
## Accent stripe
Nodes have a configurable top border stripe controlled by the `--flow-node-border-top` CSS variable. This provides a subtle accent that differentiates the top edge:
```css
.flow-container {
/* Light mode */
--flow-node-border-top: 2.5px solid #d4d4d8;
}
@media (prefers-color-scheme: dark) {
.flow-container {
--flow-node-border-top: 2.5px solid #52525b;
}
}
```
Override per node using the `style` property:
```js
{ id: '1', style: 'border-top: 3px solid #3b82f6;', data: { label: 'Blue accent' } }
```
## CSS variables for node appearance
These variables control the default look of all nodes. Override them on `.flow-container` or any parent element:
| Variable | Description |
|----------|-------------|
| `--flow-node-bg` | Node background color |
| `--flow-node-color` | Node text color |
| `--flow-node-border` | Node border shorthand (e.g., `1px solid #e4e4e7`) |
| `--flow-node-border-top` | Top border accent stripe |
| `--flow-node-border-radius` | Border radius |
| `--flow-node-padding` | Inner padding |
| `--flow-node-shadow` | Box shadow |
| `--flow-node-min-width` | Minimum node width |
| `--flow-node-transition` | Transition for state changes |
| `--flow-node-hover-border-color` | Border color on hover |
| `--flow-node-selected-border-color` | Border color when selected |
| `--flow-node-selected-shadow` | Box shadow when selected |
| `--flow-node-focus-outline` | Focus outline for keyboard navigation |
| `--flow-node-focus-outline-offset` | Focus outline offset |
### Group-specific variables
| Variable | Description |
|----------|-------------|
| `--flow-group-border-color` | Group node dashed border color |
| `--flow-group-bg` | Group node background (typically semi-transparent) |
| `--flow-node-group-padding` | Padding inside group nodes |
| `--flow-node-group-border-radius` | Border radius for group nodes |
| `--flow-node-group-font-size` | Font size for group labels |
| `--flow-node-group-text-transform` | Text transform for group labels |
| `--flow-node-group-letter-spacing` | Letter spacing for group labels |
### Shape variable
| Variable | Description |
|----------|-------------|
| `--flow-shape-border-color` | Border fill color for clipped shapes |
## Example: custom theme
```css
.flow-container {
--flow-node-bg: #1e293b;
--flow-node-color: #e2e8f0;
--flow-node-border: 1px solid #334155;
--flow-node-border-top: 3px solid #6366f1;
--flow-node-border-radius: 12px;
--flow-node-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--flow-node-hover-border-color: #6366f1;
--flow-node-selected-border-color: #818cf8;
--flow-node-selected-shadow: 0 0 0 2px #818cf8;
}
```
## See also
- [Node Basics](basics.md) -- node `class` and `style` properties
- [Shapes](shapes.md) -- shape-specific CSS classes and variables
- [Groups](groups.md) -- group node styling
# Schema Nodes
The `x-flow-schema` directive turns a node into a structured table display — header + one row per field + per-row labelled handles. It's the right primitive for ERD diagrams, GraphQL schema viewers, API payload designers, and anything where users drag connections between specific fields of one object and another.
::demo
```html
```
::enddemo
## Usage
Add a fields block in your data structure to provide the relevant content.
```html
```
The directive reads `node.data.label` and `node.data.fields` at init and re-runs on any mutation to those properties. Each field becomes one row with:
- a **target handle on the left** (for incoming edges)
- the field name + optional icon prefix
- a **type pill on the right**
- a **source handle on the right** (for outgoing edges)
Both handles carry `data-flow-handle-id=""` — edges between schema nodes set `sourceHandle` and `targetHandle` to field names, and AlpineFlow's handle infrastructure resolves the coordinates automatically.
## Field shape
```ts
interface FlowSchemaField {
name: string;
type: string;
key?: 'primary' | 'foreign';
required?: boolean;
icon?: string;
}
```
Only `name` and `type` are load-bearing. The rest drive CSS decorations:
| Flag | Class added | Default theme |
| --------------- | ------------------------------------ | --------------------------- |
| `key: primary` | `flow-schema-row--pk` | PK badge (amber) |
| `key: foreign` | `flow-schema-row--fk` | FK badge (violet) |
| `required` | `flow-schema-row--required` | red asterisk suffix |
| `icon` | renders `.flow-schema-row-icon` span | inline prefix (emoji / text) |
## Connecting fields
An edge between two schema nodes specifies which field on each side it attaches to:
```js
{
id: 'user-team',
source: 'user',
sourceHandle: 'team_id',
target: 'team',
targetHandle: 'id',
}
```
Users drag from a row's right-edge handle to another row's left-edge handle — AlpineFlow records `sourceHandle` / `targetHandle` from the handle ids automatically.
::demo
```html
```
::enddemo
## Customizing rendering
You rarely have to fork the directive to customize it — style fields with metadata + CSS, augment the rendered output per-render with **hooks** (class resolvers and decorators), or skip the directive and roll your own template. The full guide, with live examples, lives in the Schema addon docs:
**→ [Customizing rendering](../addons/schema.md#customizing-rendering)**
## See Also
- [Schema Addon](../addons/schema.md) — field CRUD with edge cascade, reference inference, JSON serialization, and inspector directives built on top of this primitive
# Edge Types
Edges are the connections between [nodes](../nodes/basics.md). AlpineFlow provides seven built-in edge types, each using a different path algorithm. Set the `type` property on an edge to choose one:
```js
{ id: 'e1', source: 'a', target: 'b', type: 'smoothstep' }
```
::demo
```html
```
::enddemo
Switch one at runtime with [`update()`](../api/flow-magic/animation.md): `$flow.update({ edges: { e1:
{ type: 'smoothstep' } } })`. Removing and re-adding the edge is not needed, and would cost its
selection and its place in the history.
## Built-in types
| Type | Description | When to use |
|------|-------------|-------------|
| `bezier` | Smooth cubic bezier curve (default). | General-purpose connections where aesthetics matter. |
| `smoothstep` | Right-angle segments with rounded corners. | Flowcharts and structured diagrams. |
| `straight` | Direct line from source to target. | Simple, minimal layouts or very short connections. |
| `orthogonal` | Right-angle routing around obstacle nodes using visibility-graph pathfinding. Falls back to `smoothstep` when no obstacles exist. | Dense diagrams where edges must not cross nodes. |
| `avoidant` | Smooth Catmull-Rom splines routed around obstacle nodes. Uses the same pathfinding as `orthogonal` but renders curves instead of right angles. Falls back to `bezier` when no obstacles exist. | Same as orthogonal, but when smoother curves are preferred. |
| `editable` | User-controlled waypoints with multiple interpolation styles. Supports double-click to add/remove control points. | When users need to manually shape edge paths. See [Editable Edges](editable.md). |
| `floating` | Auto-computes endpoints from node borders instead of fixed [handles](../handles/). Uses `pathType` to pick the underlying path generator. | Nodes with dynamic sizes or when handle placement is unknown. |
## Floating edges
Set `type: 'floating'` so endpoints are computed dynamically from node borders instead of fixed handle positions:
```js
{
id: 'e1',
source: 'a',
target: 'b',
type: 'floating',
pathType: 'smoothstep',
}
```
The edge finds the intersection of the center-to-center line with each node's rectangular boundary. As nodes move, connection points slide along the border automatically.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `pathType` | `'bezier' \| 'smoothstep' \| 'straight'` | `'bezier'` | Which path generator to use for the floating edge. |
Floating edges work well when nodes have dynamic or unknown sizes, or when you want a cleaner look without explicit handle placement.
Drag the nodes around — the edge endpoints slide along the borders:
::demo
```html
```
::enddemo
## Custom edge types
Register custom edge types via the `edgeTypes` config option. Any `type` string that does not match a built-in name is looked up in this registry:
```js
flowCanvas({
edgeTypes: {
'custom': ({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }, edge) => ({
path: `M ${sourceX} ${sourceY} L ${targetX} ${targetY}`,
labelPosition: { x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 },
}),
},
})
```
The function receives source/target coordinates and positions as its first argument, and the `edge` object itself as an optional second argument. It must return an object with `path` (an SVG path string) and `labelPosition` (an `{ x, y }` point for label placement).
The `edge` argument lets one generator read per-edge routing data off the edge (e.g. waypoints on `edge.data`) rather than needing a closure per edge — and because that data lives on the edge, it survives `toObject()` / `fromObject()`. It is optional, so existing single-argument generators are unaffected.
## Edge data shape
Every edge object accepts these properties:
```js
{
id: 'edge-1', // Required. Unique string ID.
source: 'node-a', // Required. Source node ID.
target: 'node-b', // Required. Target node ID.
sourceHandle: 'output-1', // Optional. Source handle ID.
targetHandle: 'input-1', // Optional. Target handle ID.
type: 'bezier', // Optional. 'bezier', 'smoothstep', 'step', 'straight',
// 'orthogonal', 'avoidant', 'editable', or custom.
label: 'connects to', // Optional. Center label text.
labelStart: 'from', // Optional. Label near source.
labelEnd: 'to', // Optional. Label near target.
color: '#ff0000', // Optional. Stroke color string or gradient object.
strokeWidth: 2, // Optional. Stroke width.
animated: true, // Optional. true/'dash', 'pulse', or 'dot'.
markerStart: 'arrow', // Optional. Start marker: 'arrow', 'arrowclosed', or MarkerConfig.
markerEnd: 'arrowclosed', // Optional. End marker.
selected: false, // Optional. Selection state.
hidden: false, // Optional. Hide from rendering.
deletable: true, // Optional. Per-edge delete override.
class: 'my-edge', // Optional. CSS class on the SVG path.
interactionWidth: 20, // Optional. Per-edge hit area width.
}
```
## Edge configuration options
These options are set on the `flowCanvas()` config object:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `defaultEdgeOptions` | `Partial` | -- | Properties merged into edges created at runtime (drag-connect, click-to-connect, edge-drop). Does not affect initial edges. |
| `defaultInteractionWidth` | `number` | `20` | Invisible hit area width for edge clicks. |
| `edgesReconnectable` | `boolean` | `true` | Allow edge endpoints to be dragged to different handles. |
| `reconnectSnapRadius` | `number` | `10` | Proximity radius for endpoint snap during reconnection. |
| `edgesFocusable` | `boolean` | `true` | Allow edges to receive keyboard focus via Tab. |
| `reconnectOnDelete` | `boolean` | `false` | Auto-bridge predecessors to successors when deleting middle nodes. |
## Programmatic edge management
Add and remove edges via the `$flow` magic. See the [Edges API reference](../api/flow-magic/edges.md) for all available methods.
::demo
```toolbar
```
```html
```
::enddemo
## See also
- [Markers](markers.md) -- SVG arrowheads for edge endpoints
- [Labels](labels.md) -- text labels along edges
- [Gradients](gradients.md) -- gradient colors along edge strokes
- [Animation](animation.md) -- dash, pulse, and dot animation modes
- [Editable Edges](editable.md) -- user-controlled waypoints
- [Styling](styling.md) -- colors, stroke width, CSS classes
- [Handles](../handles/) -- connection points on nodes
# Markers
Markers are SVG arrowheads rendered at edge endpoints. Two built-in types are available:
| Marker | Visual | Description |
|--------|--------|-------------|
| `'arrow'` | Open chevron | Unfilled polyline arrowhead. |
| `'arrowclosed'` | Filled triangle | Closed and filled arrowhead. |
**`arrow`** (open) and **`arrowclosed`** (filled):
::demo
```html
```
::enddemo
## Usage
Use the `markerEnd` and `markerStart` properties on an edge. Both accept a shorthand string or a full `MarkerConfig` object:
```js
// Shorthand string
{ id: 'e1', source: 'a', target: 'b', markerEnd: 'arrowclosed' }
// Full MarkerConfig object
{
id: 'e2',
source: 'a',
target: 'b',
markerEnd: {
type: 'arrowclosed',
color: '#ef4444',
width: 15,
height: 15,
},
markerStart: 'arrow',
}
```
## MarkerConfig
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `type` | `'arrow' \| 'arrowclosed'` | -- | Required. Marker shape. |
| `color` | `string` | CSS `--flow-edge-stroke` | Stroke and fill color. |
| `width` | `number` | `12.5` | Marker width in stroke-width units. |
| `height` | `number` | `12.5` | Marker height in stroke-width units. |
| `orient` | `string` | `'auto-start-reverse'` | SVG `orient` attribute. |
| `offset` | `number` | auto | Explicit distance to shift the endpoint away from the handle center. When omitted, computed automatically from handle radius and marker depth. |
Custom colored markers with `MarkerConfig` — bidirectional edge with markers on both ends:
::demo
```html
```
::enddemo
## Custom markers
Register custom marker shapes with `$flow.registerMarker(type, renderer)`. The renderer function receives resolved defaults and must return a complete SVG `` element string.
### Renderer parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Unique marker ID for the SVG `` element |
| `color` | `string` | Resolved color (from edge config or theme default) |
| `width` | `number` | Marker width in stroke-width units |
| `height` | `number` | Marker height in stroke-width units |
| `orient` | `string` | SVG `orient` attribute (usually `'auto-start-reverse'`) |
### Registration
Register via `$flow` at runtime or via ES import at build time:
```js
// Runtime (inline Alpine expressions, WireFlow x-init)
$flow.registerMarker('diamond', ({ id, color, width, height, orient }) => `
`);
// Build-time (ES import)
import { registerMarker } from '@getartisanflow/alpineflow';
registerMarker('diamond', ({ id, color, width, height, orient }) => `...`);
```
Both methods share the same global registry. Then reference the type on any edge:
```js
{ id: 'e1', source: 'a', target: 'b', markerEnd: 'diamond' }
// With MarkerConfig for custom color/size:
{ id: 'e2', source: 'a', target: 'b', markerEnd: { type: 'diamond', color: '#14B8A6', width: 15, height: 15 } }
```
Custom diamond marker in teal:
::demo
```html
```
::enddemo
## Deduplication
Markers are deduplicated in SVG `` -- identical configurations (same type and color) share a single `` element regardless of how many edges use them.
## See also
- [Edge Types](types.md) -- all available edge path types
- [Styling](styling.md) -- colors, stroke width, and CSS classes
- [Gradients](gradients.md) -- gradient colors along edge strokes
# Labels
Edges support three label positions. Labels are rendered as HTML `
` elements (not SVG ``), so they support full HTML/CSS styling.
All three label positions — center, start, and end:
::demo
```html
```
::enddemo
## Label positions
| Property | Position | Default offset |
|----------|----------|----------------|
| `label` | Center of the path | `labelPosition: 0.5` (0 = source, 1 = target) |
| `labelStart` | Near the source end | `labelStartOffset: 30` (flow-coordinate pixels from source) |
| `labelEnd` | Near the target end | `labelEndOffset: 30` (flow-coordinate pixels from target) |
## Label visibility
Control when labels appear with `labelVisibility`:
| Value | Behavior |
|-------|----------|
| `'always'` | Always visible (default). |
| `'hover'` | Visible on hover or when the edge is selected. |
| `'selected'` | Visible only when the edge is selected. |
Hover each edge to see the label appear — the top edge is always visible, the bottom only on hover:
::demo
```html
```
::enddemo
## HTML labels
Label text is written with `textContent`, so markup in a label shows as the tags themselves. Set `labelHtml: true` on the edge to render its labels as HTML instead — for a label that needs a line break, an icon, or a piece of emphasis:
::demo
```html
```
::enddemo
The flag covers all three positions — `label`, `labelStart` and `labelEnd` — and the value goes to `innerHTML` unchanged. Like any HTML you hand a framework, it is trusted: pass anything a user typed through your own escaping or sanitiser first.
## Example
```js
{
id: 'e1',
source: 'a',
target: 'b',
label: 'main',
labelStart: '1',
labelEnd: '*',
labelVisibility: 'hover',
}
```
## See also
- [Edge Types](types.md) -- all available edge path types
- [Styling](styling.md) -- colors, stroke width, and CSS classes
- [Markers](markers.md) -- SVG arrowheads for edge endpoints
# Gradients
Set `color` to a gradient object for a two-color linear gradient along the edge stroke:
```js
{
id: 'e1',
source: 'a',
target: 'b',
color: { from: '#22c55e', to: '#ef4444' },
}
```
::demo
```html
```
::enddemo
## Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `color` | `string \| { from, to }` | CSS variable | Solid color string or gradient object. |
| `gradientDirection` | `'source-target' \| 'target-source'` | `'source-target'` | Which end of the edge gets the `from` color. |
The top edge flows amber-to-violet (default direction). The bottom edge reverses it with `gradientDirection: 'target-source'`:
::demo
```html
```
::enddemo
## How it works
The gradient is implemented as an SVG `` in `userSpaceOnUse` coordinates, updated reactively as nodes move. Each unique gradient pair produces its own `` element in SVG ``.
## See also
- [Edge Types](types.md) -- all available edge path types
- [Styling](styling.md) -- colors, stroke width, and CSS classes
- [Markers](markers.md) -- marker colors can complement gradient edges
# Edge Animation
Enable edge animation with the `animated` property:
```js
// Scrolling dashes (default animation)
{ id: 'e1', source: 'a', target: 'b', animated: true }
// Specific mode
{ id: 'e2', source: 'a', target: 'b', animated: 'pulse' }
```
Left to right: **dash**, **pulse**, and **dot**:
::demo
```html
```
::enddemo
## Animation modes
| Value | CSS class | Visual |
|-------|-----------|--------|
| `true` / `'dash'` | `.flow-edge-animated` | Scrolling dash pattern along the edge stroke. |
| `'pulse'` | `.flow-edge-pulse` | Opacity breathing effect (fades between 1.0 and `--flow-edge-pulse-min-opacity`). |
| `'dot'` | `.flow-edge-dot` | A circle element traveling along the path from source to target. |
## Timing overrides
| Property | Type | Applies to | Description |
|----------|------|------------|-------------|
| `animationDuration` | `string` | All modes | CSS time value (e.g. `'1s'`, `'300ms'`). Overrides the mode's CSS variable. |
| `particleColor` | `string` | `'dot'` | Fill color. Overrides `--flow-edge-dot-fill`. |
| `particleSize` | `number` | `'dot'` | Circle radius in SVG units. Overrides `--flow-edge-dot-size`. |
## CSS variables
| Variable | Description |
|----------|-------------|
| `--flow-edge-animated-duration` | Duration of the dash scroll animation. |
| `--flow-edge-animated-dasharray` | Dash pattern for the dash animation. |
| `--flow-edge-pulse-duration` | Duration of the pulse breathing cycle. |
| `--flow-edge-pulse-min-opacity` | Minimum opacity during pulse animation. |
| `--flow-edge-dot-duration` | Duration for the dot to travel the full path. |
| `--flow-edge-dot-size` | Circle radius for the dot animation. |
| `--flow-edge-dot-fill` | Fill color for the dot animation. |
## See also
- [Edge Types](types.md) -- all available edge path types
- [Styling](styling.md) -- colors, stroke width, and CSS classes
# Editable Edges
Set `type: 'editable'` to allow users to place and drag waypoints along an edge:
```js
{
id: 'e1',
source: 'a',
target: 'b',
type: 'editable',
controlPoints: [{ x: 300, y: 150 }],
pathStyle: 'catmull-rom',
}
```
Double-click the edge to add waypoints. Drag them to reshape. Double-click a waypoint to remove it:
::demo
```html
```
::enddemo
## Interaction
- **Double-click on the edge path** to add a new control point at the click position.
- **Double-click on an existing control point** to remove it.
- **Double-click on a midpoint indicator** to insert a control point at that segment.
- **Drag a control point** to reposition it.
## Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `controlPoints` | `{ x, y }[]` | `[]` | Array of waypoint positions in flow coordinates. |
| `pathStyle` | `'linear' \| 'step' \| 'smoothstep' \| 'catmull-rom' \| 'bezier'` | `'bezier'` | Interpolation between control points. |
| `showControlPoints` | `boolean` | `false` | Always show control point handles (vs. only when selected). |
## Path styles
- **`linear`** -- Straight line segments between each waypoint.
- **`step`** -- Orthogonal L-bends with sharp corners (border radius 0).
- **`smoothstep`** -- Orthogonal routing with rounded corners between each pair of waypoints.
- **`catmull-rom`** / **`bezier`** -- Smooth Catmull-Rom spline through all waypoints (these two are equivalent).
**Linear** — straight segments between waypoints:
::demo
```html
```
::enddemo
**Catmull-Rom** — smooth spline through all waypoints:
::demo
```html
```
::enddemo
## Events
Editable edges emit events when control points are modified:
| Event | Payload | When |
|-------|---------|------|
| `edge-control-point-change` | `{ edge, action, index }` | Control point added, removed, or moved. |
| `edge-control-point-context-menu` | `{ edge, index, event, position }` | Right-click on a control point. |
The `action` field is `'add'`, `'remove'`, or `'move'`. The `index` is the position in the `controlPoints` array.
### Listening for changes
```html
```
## Persisting control points
Control points are stored on the edge object and update reactively. To save them to a backend:
```js
// Via config callback
onEdgesPatch: (detail) => {
// Save to server whenever edges change
fetch('/api/edges', {
method: 'POST',
body: JSON.stringify(detail.patches),
});
}
// Or listen for the specific event
@edge-control-point-change="
const edge = $event.detail.edge;
$wire.saveEdgeControlPoints(edge.id, edge.controlPoints);
"
```
The `controlPoints` array is part of the edge object, so it is included in `$flow.toObject()` serialization and restored by `$flow.fromObject()`.
## Complete example
```html
```
Double-click the edge to add waypoints. Drag waypoints to reshape the path. Double-click a waypoint to remove it.
## See also
- [Edge Types](types.md) -- all available edge path types and the edge data shape
- [Styling](styling.md) -- colors, stroke width, and CSS classes
# Edge Styling
Control edge appearance with inline properties or CSS classes.
Custom color and stroke width on each edge:
::demo
```html
```
::enddemo
## Style properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `color` | `string \| { from, to }` | `--flow-edge-stroke` | Stroke color or [gradient](gradients.md). |
| `strokeWidth` | `number` | `1.5` | Visible stroke width in SVG units. |
| `interactionWidth` | `number` | `20` (from `defaultInteractionWidth`) | Invisible hit area width for click/hover detection. |
| `class` | `string` | -- | CSS class(es) added to the edge `` element. |
| `style` | `string \| Record` | -- | Inline styles on the edge element. |
## Status classes
AlpineFlow provides six built-in status classes for semantic edge coloring. Apply them via the `class` property:
```js
{ id: 'e1', source: 'a', target: 'b', class: 'flow-edge-success' }
```
| Class | Color | Use case |
|-------|-------|----------|
| `flow-edge-success` | `#10b981` (green) | Successful or valid connections. |
| `flow-edge-warning` | `#f59e0b` (amber) | Connections that need attention. |
| `flow-edge-danger` | `#ef4444` (red) | Error or invalid connections. |
| `flow-edge-info` | `#3b82f6` (blue) | Informational or neutral connections. |
| `flow-edge-primary` | `#8b5cf6` (purple) | Primary or highlighted connections. |
| `flow-edge-highlight` | `#64748b` (slate) | Subtle emphasis without a semantic color. |
All status classes set `stroke-width: 2.5` in addition to their color. These colors are defined in the theme CSS and can be overridden by customizing the theme file.
::demo
```html
```
::enddemo
## Interaction width
Each edge has an invisible hit area that makes it easier to click and hover. The `interactionWidth` property controls this area's width in SVG units. The default is `20`, set globally via `defaultInteractionWidth` in the canvas config.
A narrow interaction width requires precise clicks, while a wide one makes edges easy to select — useful for touch devices or dense diagrams.
```js
// Per-edge override
{ id: 'e1', source: 'a', target: 'b', interactionWidth: 5 }
// Global default
flowCanvas({ defaultInteractionWidth: 30 })
```
::demo
```html
```
::enddemo
## Combining properties
Properties can be combined freely:
```js
{
id: 'e1',
source: 'a',
target: 'b',
color: '#8b5cf6',
strokeWidth: 3,
class: 'my-custom-edge',
animated: 'pulse',
markerEnd: 'arrowclosed',
}
```
When both `color` and a status class are set, the inline `color` property takes precedence over the CSS class color.
## See also
- [Edge Types](types.md) -- all available edge path types and configuration
- [Gradients](gradients.md) -- gradient colors along edge strokes
- [Markers](markers.md) -- SVG arrowheads for edge endpoints
- [Animation](animation.md) -- dash, pulse, and dot animation modes
- [Labels](labels.md) -- text labels along edges
# Handle Positions
The `x-flow-handle` directive marks an element as a connection handle -- either a **source** (initiates connections) or a **target** (receives connections). Handles must be placed inside an element with `x-flow-node`.
Default positions — target on top, source on bottom:
::demo
```html
```
::enddemo
## Argument
The argument is **required** and specifies the handle type:
| Argument | Description |
|-----------|----------------------------------------------------------|
| `:source` | Initiates connections (cursor changes to crosshair) |
| `:target` | Receives connections |
```html
```
## Position modifiers
Control where the handle is placed on the node border:
| Modifier | Description | Default for |
|------------|---------------|-------------|
| `.top` | Top edge | target |
| `.right` | Right edge | -- |
| `.bottom` | Bottom edge | source |
| `.left` | Left edge | -- |
When no position modifier is provided, the default is `bottom` for source handles and `top` for target handles.
Left-to-right flow — source on right, target on left:
::demo
```html
```
::enddemo
### Corner placement
Position modifiers can be **combined** for corner placement:
| Compound modifier | Position |
|--------------------|----------------|
| `.top.left` | Top-left |
| `.top.right` | Top-right |
| `.bottom.left` | Bottom-left |
| `.bottom.right` | Bottom-right |
```html
```
::demo
```html
```
::enddemo
## Dynamic position via expression
The expression is **optional** and can be either a string (handle ID) or an object with `id` and `position` properties.
### String expression (handle ID)
```html
```
When omitted, the handle ID defaults to the argument name (`"source"` or `"target"`).
### Object expression (ID and dynamic position)
```html
```
| Property | Type | Description |
|------------|------------------|--------------------------------------------|
| `id` | `string` | Handle identifier (falls back to type) |
| `position` | `HandlePosition` | Reactively updates the handle's position |
This is useful when the handle position needs to change at runtime based on component state.
Click the buttons to move the source handle — the edge re-routes automatically:
::demo
```toolbar
```
```html
```
::enddemo
## Per-node handle positions
Set default handle positions on individual nodes with `sourcePosition` and `targetPosition`. Handles without a position modifier inherit the node's setting:
```js
nodes: [
{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'Left to Right' },
sourcePosition: 'right', targetPosition: 'left' },
{ id: 'b', position: { x: 0, y: 150 }, data: { label: 'Top to Bottom' },
sourcePosition: 'bottom', targetPosition: 'top' },
]
```
This is useful when different nodes in the same flow need different orientations — for example, a horizontal pipeline feeding into a vertical decision tree.
::demo
```html
```
::enddemo
## Position resolution order
Handle position is resolved in this priority order:
1. Compound modifier (e.g., `.top.left`)
2. Single modifier (e.g., `.right`)
3. Object expression `position` property
4. `data-flow-handle-position` HTML attribute
5. Parent node's `sourcePosition` / `targetPosition` property (reactive)
6. Default: `bottom` for source, `top` for target
## Multiple handles
A node can have any number of source and target handles. Add multiple `x-flow-handle` directives and position them independently:
```html
```
When a node has multiple handles of the same type, each handle needs a unique **name** to distinguish it. Pass a string expression to the directive to assign the name:
```html
```
::demo
```html
```
::enddemo
### Routing edges to named handles
Reference handle names on edges with `sourceHandle` and `targetHandle` to route connections to specific ports:
```js
edges: [
{ id: 'e1', source: 'nodeA', sourceHandle: 'out-1', target: 'nodeB', targetHandle: 'in-1' },
{ id: 'e2', source: 'nodeA', sourceHandle: 'out-2', target: 'nodeC', targetHandle: 'in-1' },
]
```
When omitted, edges connect to the first handle of the matching type. When specified, the edge path starts or ends at the exact position of the named handle — even if multiple handles share the same side of the node.
This pattern is the foundation for building data-flow editors, pipeline builders, and any diagram where nodes have distinct input/output ports.
### Hidden modifier
The `.hidden` modifier hides the handle visually while keeping it functional:
```html
```
## CSS classes
| Class | Applied when |
|-----------------------------|------------------------------------------------|
| `.flow-handle` | Always |
| `.flow-handle-source` | Handle type is source |
| `.flow-handle-target` | Handle type is target |
| `.flow-handle-active` | Handle is being hovered or snapped during drag |
| `.flow-handle-valid` | Target can accept the pending connection |
| `.flow-handle-invalid` | Target cannot accept the pending connection |
| `.flow-handle-limit-reached`| Connection rejected specifically due to limit |
During a connection drag, all target handles in the canvas receive either `.flow-handle-valid` or `.flow-handle-invalid` so you can style valid drop targets differently from invalid ones.
# Handle Validation
AlpineFlow provides per-handle validation and connection limits to control which connections are allowed.
## x-flow-handle-validate
Attaches a custom validator function to a handle. The validator is called during connection completion, after built-in checks (cycle prevention, duplicate detection, connectable state), and must return a boolean.
Must be placed on an element that also has `x-flow-handle`.
Try connecting "Source" to "Blocked" — the connection is rejected. "Allowed" accepts it:
::demo
```html
```
::enddemo
### Validator expression
A function that receives a `Connection` object and returns `boolean`:
```ts
(connection: Connection) => boolean
```
The `Connection` object has the following properties:
| Property | Type | Description |
|----------------|---------------------|----------------------------|
| `source` | `string` | Source node ID |
| `sourceHandle` | `string | undefined`| Source handle ID |
| `target` | `string` | Target node ID |
| `targetHandle` | `string | undefined`| Target handle ID |
### Validator usage
```html
```
Validators on both the source and target handles are checked. If either returns `false`, the connection is rejected.
### Validation chain order
When a connection is attempted, checks run in this order:
1. Node `connectable` flag
2. Built-in checks (cycle prevention, duplicate edges)
3. Handle limits (`x-flow-handle-limit`)
4. Handle validators (`x-flow-handle-validate`)
5. Global `isValidConnection` callback
### Visual feedback
During a drag, the connection line and target handles reflect validation state:
- **Valid handles** receive the `.flow-handle-valid` CSS class (default: green ring).
- **Invalid handles** receive the `.flow-handle-invalid` CSS class (default: red ring).
- **Connection line** turns red (`--flow-connection-line-invalid`) when hovering over an invalid target.
## x-flow-handle-limit
Sets the maximum number of connections a handle can have. When the handle already has that many connections, new connections to or from it are rejected.
Must be placed on an element that also has `x-flow-handle`.
The target node below accepts only 1 connection — try connecting both sources:
::demo
```html
```
::enddemo
### Limit expression
A number representing the maximum connection count. Values of `0` or below are ignored (no limit).
### Limit usage
```html
```
```html
```
When a connection is rejected because of a limit, the target handle receives the `.flow-handle-limit-reached` CSS class in addition to `.flow-handle-invalid`, so you can style it distinctly:
```css
.flow-handle-limit-reached {
background: orange;
}
```
## See also
For async server-gated validation — checking a server for duplicates, policy, or rate limits before committing an edge — see the [Connect Validator](../guides/connect-validator.md) guide.
# Handle Connectable
The `x-flow-handle-connectable` directive controls whether a specific handle can initiate connections, receive connections, or both. This operates independently of the node-level `connectable` flag, giving per-handle control.
Must be placed on an element that also has `x-flow-handle`.
Try connecting from each node — the left node's source cannot initiate, and the right node's target cannot receive:
::demo
```html
```
::enddemo
## Expression
A boolean value. When `false`, the specified direction is disabled. Defaults to `true` when no expression is provided.
## Modifiers
| Modifier | Description |
|----------|------------------------------------------------|
| _(none)_ | Controls both initiating and receiving |
| `.start` | Controls only initiating connections (drag out) |
| `.end` | Controls only receiving connections (drop in) |
## Usage
### Disable starting connections
```html
```
The left node's source handle is disabled — try dragging from it (nothing happens). You can still connect to it from the right node:
::demo
```html
```
::enddemo
### Disable receiving connections
```html
```
The right node's target handle is disabled — it shows as invalid when you try to drop a connection on it:
::demo
```html
```
## Behavior details
When `_flowHandleConnectableStart` is `false` on a source handle, dragging from it does nothing. When `_flowHandleConnectableEnd` is `false` on a target handle, it is marked `.flow-handle-invalid` during connection drags and cannot receive connections. Undefined values default to connectable.
## Interaction with node-level connectable
The node-level `connectable` flag (`node.connectable`) is checked first in the validation chain. If a node has `connectable: false`, all its handles are non-connectable regardless of `x-flow-handle-connectable`. The per-handle directive provides finer-grained control when the node itself is connectable.
## Interaction with locked state
When the canvas is locked, all interactions including connections are disabled. The `x-flow-handle-connectable` directive has no effect while the canvas is locked.
# Drag to Connect
The default connection method. Drag from a source handle to a target handle to create an edge.
Drag from any source handle (bottom) to a target handle (top) to create a connection:
::demo
```html
```
::enddemo
## How it works
1. Pointer down on a source handle starts the connection.
2. A temporary dashed line follows the cursor.
3. Valid target handles highlight as the cursor approaches (within `connectionSnapRadius`).
4. Release on a valid target handle to create the edge.
5. Release on empty space to cancel (or trigger `onEdgeDrop` if configured).
Auto-pan activates when dragging near the canvas edge (controlled by `autoPanOnConnect`, default `true`).
## Connection line
Customize the temporary line shown during connection drag.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `connectionLineType` | `'straight' \| 'bezier' \| 'smoothstep' \| 'step'` | `'straight'` | Path algorithm for the temporary line. |
| `connectionLineStyle` | `{ stroke?, strokeWidth?, strokeDasharray? }` | Themed defaults | Style overrides for the line. |
| `connectionLine` | `(props: ConnectionLineProps) => SVGElement` | -- | Full custom renderer. When set, overrides `connectionLineType`. Receives `fromX`, `fromY`, `toX`, `toY`, `source`, `sourceHandle`. |
**Straight** (default):
::demo
```html
```
::enddemo
**Bezier**:
::demo
```html
```
::enddemo
**Smoothstep**:
::demo
```html
```
::enddemo
**Step**:
::demo
```html
```
::enddemo
## Edge reconnection
Drag existing edge endpoints to reconnect them to different handles.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `edgesReconnectable` | `boolean` | `true` | Global toggle for edge reconnection. |
| `reconnectSnapRadius` | `number` | `10` | Proximity radius in flow pixels for detecting endpoint hover. |
Try dragging either end of the edge to reconnect it to a different node:
::demo
```html
```
::enddemo
### Per-edge control
Each edge has a `reconnectable` property:
| Value | Behavior |
|-------|----------|
| `true` | Both endpoints can be dragged (default). |
| `false` | Neither endpoint can be dragged. |
| `'source'` | Only the source endpoint can be dragged. |
| `'target'` | Only the target endpoint can be dragged. |
### Reconnection events
| Event | Payload | When |
|-------|---------|------|
| `reconnect-start` | `{ edge, handleType }` | Drag begins on an edge endpoint. |
| `reconnect` | `{ oldEdge, newConnection }` | Edge successfully reconnected. |
| `reconnect-end` | `{ edge, successful }` | Drag ends (whether successful or cancelled). |
### Connection mode
The `connectionMode` config controls handle matching during both new connections and reconnection:
| Value | Behavior |
|-------|----------|
| `'strict'` | Source handles only snap to target handles (default). |
| `'loose'` | Source handles can snap to any handle type. |
In `loose` mode, you can connect source-to-source or target-to-target:
::demo
```html
```
::enddemo
## isValidConnection callback
The `isValidConnection` callback provides a final custom check after all built-in validation has passed:
```html
```
This runs as the last synchronous step in the validation chain, after connectable flags, cycle prevention, duplicate checks, handle limits, and handle validators. For async server-gated validation, see the [Connect Validator](../guides/connect-validator.md) guide.
Try connecting to the "Blocked" node — the connection will be rejected:
::demo
```html
```
::enddemo
## Edge drop
Create a new node when a connection is dropped on empty canvas space.
| Property | Type | Description |
|----------|------|-------------|
| `onEdgeDrop` | `(detail) => FlowNode \| null` | Return a node object to auto-create it and connect it to the source. Return `null` to cancel. |
| `edgeDropPreview` | `(detail) => string \| HTMLElement \| null` | Customize the ghost preview shown during drag. String for label text, HTMLElement for custom content, null to hide. |
The auto-created edge uses `defaultEdgeOptions` and passes through `isValidConnection`.
Drag from a handle and release on empty space to auto-create a node:
::demo
```html
```
::enddemo
# Click to Connect
Create edges by clicking handles instead of dragging -- useful for accessibility and precision.
Click a source handle (bottom), then click a target handle (top) to create an edge:
::demo
```html
```
::enddemo
## Configuration
Enabled by default via `connectOnClick: true`.
```html
```
## How it works
1. Click a source handle to select it. The handle receives the `.flow-handle-active` CSS class.
2. Valid target handles highlight with `.flow-handle-valid`, invalid targets with `.flow-handle-invalid`.
3. Click a valid target handle to complete the connection.
4. Clicking empty space or pressing **Escape** cancels.
The same validation chain applies as with drag-to-connect: connectable flags, cycle prevention, duplicate checks, handle limits, handle validators, and `isValidConnection`.
## Disabling click-to-connect
Set `connectOnClick: false` to disable this behavior and require drag connections only:
```html
```
# Multi-Connect
Create edges from multiple selected nodes in a single drag.
Select both left nodes (Shift+click), then drag from either source handle to the target:
::demo
```html
```
::enddemo
## Configuration
```html
```
## How it works
1. Select multiple nodes (Shift+click or drag-select — on touch devices, two-finger tap enters selection mode — see [Touch & Mobile](../interaction/touch.md)).
2. Drag from any selected node's source handle.
3. Connection lines appear from **all** selected nodes' source handles to the cursor.
4. Drop on a valid target to create all valid connections in a single batch.
5. The `multi-connect` event fires with the array of created connections.
Each connection in the batch is validated individually through the standard validation chain. Connections that fail validation are silently skipped -- only valid connections are created.
# Easy Connect
Connect nodes without requiring precise handle targeting. Hold a modifier key and drag from anywhere on a node's body to start a connection.
Hold **Alt/Option** and drag from anywhere on a node to start a connection:
::demo
```html
```
::enddemo
## Configuration
```html
```
## How it works
Hold the modifier key and drag from anywhere on a node's body. AlpineFlow finds the nearest source handle on that node and starts a connection from it. The connection then behaves exactly like a standard drag-to-connect -- drop on a valid target handle to create the edge.
## Modifier keys
| Value | Key |
|-------|-----|
| `'alt'` | Alt/Option (default) |
| `'meta'` | Cmd (macOS) / Win (Windows) |
| `'shift'` | Shift |
```html
```
Easy connect uses the same validation chain as standard drag connections. The only difference is how the connection is initiated -- from the node body instead of a specific handle.
# Proximity Connect
Auto-create edges when dragging nodes close together. When a node is dragged within a configurable distance of another node, an edge is automatically created between them.
Drag a node close to another to auto-create an edge:
::demo
```html
```
::enddemo
## Configuration
```html
```
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `proximityConnect` | `boolean` | `false` | Enable proximity connections. |
| `proximityConnectDistance` | `number` | `150` | Distance threshold in flow pixels. |
| `proximityConnectConfirm` | `boolean` | `false` | Show visual confirmation before creating the edge. |
| `onProximityConnect` | `function` | -- | Callback to validate or reject. Return `false` to prevent the edge. |
## Direction inference
Direction is inferred automatically: the node further left becomes the source. If X positions are similar (within 30px), the higher node becomes the source.
## Validation callback
Use `onProximityConnect` to add custom logic before the edge is created:
```html
```
The callback receives `source` (node ID), `target` (node ID), and `distance` (pixels). Return `false` to prevent the edge from being created.
Drag "Source" close to either target — "Blocked" will reject the connection:
::demo
```html
```
::enddemo
## Confirmation mode
When `proximityConnectConfirm` is `true`, a visual indicator is shown when nodes are close enough to connect, but the edge is not created until the user releases the drag. This gives users a chance to see the proposed connection before committing.
Drag a node close — notice the preview edge appears before you release:
::demo
```html
```
::enddemo
# Viewport
The viewport determines which portion of the diagram is visible. AlpineFlow provides extensive options for controlling panning, zooming, coordinate conversion, and viewport animation.
Pan by dragging the background, scroll to zoom (pinch to zoom on touch), and click Fit View to reset:
::demo
```toolbar
```
```html
```
::enddemo
## Pan and zoom
Enable or disable panning and zooming with config options:
```html
```
### Mouse button control
Restrict which mouse button triggers panning with `panOnDrag`:
```html
```
Pass an array of mouse button indices (0 = left, 1 = middle, 2 = right).
### Scroll panning
Use scroll events to pan instead of zoom:
```html
```
`panOnScrollDirection` accepts `'both'`, `'horizontal'`, or `'vertical'`. `panOnScrollSpeed` is a multiplier for scroll-to-pan speed.
### Excluding elements from pan and zoom
Two classes let an element opt out of a canvas gesture, so a drag or wheel over it is handled locally instead of moving the viewport:
- **`nopan`** — a press-drag on the element (or anything inside it) won't pan the canvas. Nodes carry this automatically, which is why dragging a node moves the node rather than the viewport. Add it to a custom on-canvas control — a slider, a draggable widget — that needs its own drag.
- **`nowheel`** — a wheel over the element scrolls its content instead of zooming the canvas. Add it to a scrollable panel or list you place on the canvas.
```html
...
```
Neither class blocks the other: `nopan` gates panning only, `nowheel` gates wheel zoom only. Rename them with the `noPanClassName` / `noWheelClassName` config options, or set `noWheelClassName` to `undefined` to disable the wheel opt-out entirely.
## Keyboard pan
Hold the **Space** key to enter grab-cursor pan mode. While held, click and drag to pan the viewport regardless of other interaction settings.
## Viewport element
The viewport is the pannable, zoomable layer that contains all nodes. Place it as a direct child of the `flowCanvas()` scope element using the `x-flow-viewport` directive:
```html
...
```
The viewport element:
- Applies the viewport transform (`translate` + `scale`) to the element
- Creates and manages the edge SVG layer as its first child
- Reactively renders edge SVG elements from the `edges` array
- Handles edge visibility (hidden/filtered nodes hide their edges)
- Removes pre-rendered static edges when reactive edges take over (SSR hydration)
- Gets a `.flow-viewport` CSS class automatically
Edges are rendered automatically -- you don't write edge markup. Node templates go inside the viewport via `x-for`.
## fitView
Fit all nodes into the viewport with optional padding and animation:
```js
$flow.fitView({ padding: 0.2, duration: 300 })
```
- `padding` -- Fraction of viewport to leave as margin (default `0.1`).
- `duration` -- Animation duration in milliseconds. Omit or set to `0` for instant.
### On initialization
Automatically fit the view when the canvas first renders:
```html
```
### Via directive
Use the `x-flow-action` directive on a button:
```html
```
Pan around, then click Fit View to animate back:
::demo
```toolbar
```
```html
```
::enddemo
## setViewport / panBy / setCenter
### setViewport
Set an exact viewport position and zoom level:
```js
$flow.setViewport({ x: 100, y: 200, zoom: 1.5 }, { duration: 300 })
```
### panBy
Pan the viewport by a relative offset in screen pixels:
```js
$flow.panBy(dx, dy)
```
### setCenter
Center the viewport on specific flow coordinates:
```js
$flow.setCenter(x, y, zoom)
```
::demo
```toolbar
```
```html
```
::enddemo
## fitBounds
Fit a specific rectangular area into the viewport:
```js
$flow.fitBounds(
{ x: 0, y: 0, width: 500, height: 300 },
{ padding: 0.1, duration: 300 }
)
```
Useful for focusing on a subset of nodes or a particular region of the diagram.
Click each button to focus on a different region:
::demo
```toolbar
```
```html
```
::enddemo
## Coordinate conversion
Convert between screen (DOM) coordinates and flow (diagram) coordinates:
```js
// Screen position to flow position
const flowPos = $flow.screenToFlowPosition(clientX, clientY)
// Flow position to screen position
const screenPos = $flow.flowToScreenPosition(flowX, flowY)
```
These are essential when handling native DOM events (e.g., mouse clicks) and mapping them onto the diagram's coordinate space.
## Viewport boundaries
Restrict how far the user can pan with `translateExtent`:
```html
```
The value is `[[minX, minY], [maxX, maxY]]` in flow coordinates. The viewport will not pan beyond these bounds.
Try panning — the viewport stops at the boundary edges. To also constrain node positions, use `nodeExtent`:
::demo
```html
```
::enddemo
## Viewport culling
Viewport culling skips rendering nodes and edges that are outside the visible area, improving performance for large diagrams:
```html
```
- `viewportCulling` -- `'auto'` (default), `true`, or `false`. With `'auto'`, culling activates automatically once the node count reaches `cullingAutoThreshold`. `true`/`false` force it on/off regardless of node count.
- `cullingAutoThreshold` -- Node count at which `viewportCulling: 'auto'` turns culling on (default `150`).
- `cullingBuffer` -- Extra pixels beyond the viewport edge before an element is culled (default `100`). A larger buffer prevents pop-in when panning quickly.
## Auto-pan
Automatically pan the viewport when the user drags a node or connection near the canvas edge:
```html
```
- `autoPanOnNodeDrag` -- Pan while dragging a node near the edge.
- `autoPanOnConnect` -- Pan while drawing a connection near the edge.
- `autoPanSpeed` -- Speed multiplier for auto-pan velocity.
## Events
Listen for viewport changes on the canvas element:
| Event | Description |
|-------|-------------|
| `viewport-change` | Fires whenever the viewport position or zoom changes |
| `viewport-move-start` | Fires when a pan/zoom interaction begins |
| `viewport-move` | Fires continuously during a pan/zoom interaction |
| `viewport-move-end` | Fires when a pan/zoom interaction ends |
```html
```
# Background
AlpineFlow renders a configurable grid pattern behind the diagram. The background moves and scales with the viewport to provide spatial context while panning and zooming.
::demo
```html
```
::enddemo
## Patterns
Set the background pattern with the `background` config option:
```html
```
Available patterns:
| Value | Description |
|-------|-------------|
| `'dots'` | Dot grid (default) |
| `'lines'` | Horizontal and vertical lines |
| `'cross'` | Crosshair marks at grid intersections |
| `'none'` | No background pattern |
**Lines**:
::demo
```html
```
::enddemo
**Cross**:
::demo
```html
```
::enddemo
## Customization
### Grid spacing
Control the distance between grid points or lines with `backgroundGap`:
```html
```
The default gap is `20` pixels.
### Pattern color
Set the pattern color with `patternColor`. Any valid CSS color value works, including `rgba` for transparency:
```html
```
::demo
```html
```
::enddemo
### CSS variables
Override background styling globally through CSS variables:
| Variable | Description |
|----------|-------------|
| `--flow-bg-color` | Canvas background fill color |
| `--flow-bg-pattern-color` | Pattern element color (dots, lines, crosses) |
| `--flow-bg-pattern-gap` | Grid spacing |
## Multi-layer
For richer grids, pass an array of `BackgroundLayer` objects. Each layer renders independently, allowing you to combine patterns at different scales:
```html
```
This example draws a fine dot grid with a coarser line grid overlaid on top, creating a subdivided grid effect.
::demo
```html
```
::enddemo
## Dark mode
Pattern colors auto-adjust when using the default theme's CSS variables. The theme file sets appropriate values for both light and dark modes, so backgrounds remain visible without manual configuration.
To customize dark mode colors explicitly, override the CSS variables within a dark mode selector in your stylesheet.
# Controls Panel
The controls panel provides built-in buttons for common viewport operations like zooming, fitting the view, and toggling interactivity.
::demo
```html
```
::enddemo
## Enabling
Enable the controls panel with the `controls` config option:
```html
```
### Position
Set placement with `controlsPosition`:
```html
```
Supported positions: `'top-left'`, `'top-right'`, `'bottom-left'` (default), `'bottom-right'`.
### Orientation
Controls stack vertically by default. Switch to horizontal layout:
```html
```
**Horizontal orientation** with bottom-right placement:
::demo
```html
```
::enddemo
### Animation
By default the zoom-in, zoom-out, and fit-view buttons snap the viewport instantly. Set `controlsDuration` (in milliseconds) to animate the move instead, so the buttons glide the same way a double-click zoom or a layout does:
```html
```
The default of `0` keeps the instant behaviour.
## Buttons
Toggle individual buttons with these config options:
| Option | Description | Default |
|--------|-------------|---------|
| `controlsShowZoom` | Show zoom in (+) and zoom out (-) buttons | `true` |
| `controlsShowFitView` | Show a button that fits all nodes into the viewport | `true` |
| `controlsShowInteractive` | Show a button that toggles pan, zoom, and drag interactivity | `true` |
| `controlsShowResetPanels` | Show a button that resets panel positions to their defaults | `false` |
```html
```
## External container
Render the controls panel outside the canvas element by providing a CSS selector with `controlsContainer`:
```html
```
This is useful when you want the controls to appear in a sidebar, toolbar, or any other part of the page layout.
> **Styling note:** External controls live outside the `.flow-container` element, so they don't inherit theme CSS variables. Add `.flow-container` to the external container element, or set the `--flow-controls-*` variables on it directly.
::demo
```toolbar
External controls:
```
```html
```
::enddemo
## Styling
Customize button appearance with CSS variables:
| Variable | Description |
|----------|-------------|
| `--flow-controls-btn-bg` | Button background color |
| `--flow-controls-btn-border` | Button border |
| `--flow-controls-btn-color` | Button icon/text color |
| `--flow-controls-btn-hover-bg` | Button background on hover |
| `--flow-controls-gap` | Gap between buttons |
| `--flow-controls-btn-width` | Button width |
| `--flow-controls-btn-height` | Button height |
| `--flow-controls-btn-border-radius` | Button corner rounding |
The rounding is applied to the strip's own ends — the first and last **direct** children of
`.flow-controls`. A control you add yourself inside a wrapper (a trigger with a fly-out to anchor,
say) is left square, and rounding it belongs to whoever put it there.
# Minimap
The minimap renders a scaled-down overview of the entire diagram in a corner of the canvas. A viewport indicator rectangle shows which portion of the diagram is currently visible.
::demo
```html
```
::enddemo
## Enabling
Enable the minimap by setting the `minimap` config option:
```html
```
Control placement with `minimapPosition`:
```html
```
Supported positions: `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'` (default).
### Size
The minimap is 200 by 150 unless it is told otherwise:
```html
```
This is not only how big the picture is. The scale that fits the graph into it and the rectangle
that marks the viewport are both computed against the box, so a minimap whose ratio does not match
the canvas draws a viewport marker that is not the shape of the viewport.
A canvas that changes shape with the window wants a minimap that follows it. Nothing here watches —
what counts as "too big" is the page's business — so a consumer watching its own container calls:
```js
$flow.resizeMinimap(160, 60);
```
which redraws at the new size. A width or height that is not positive is ignored.
#### Persisting the size
The size is a preference, so both directions are wired.
Outbound, `resizeMinimap()` writes the new box back into the config and fires `minimap-resize` — but
only when the box actually changed, so a `ResizeObserver` that fires on every frame does not turn
into a round-trip per frame:
```js
onMinimapResize({ width, height }) {
// save it against the user
}
```
Inbound, `minimapWidth` and `minimapHeight` are patchable, so a saved size is applied the same way
any other runtime option is:
```js
$flow.patchConfig({ minimapWidth: 160, minimapHeight: 60 });
```
Under Livewire this needs no new command: `flow:patchConfig` is already bridged, and
`minimap-resize` is in the outbound payload map — it reaches the server as two numbers, width and
height.
## Interaction
The minimap supports two interaction modes:
- **`minimapPannable`** -- Click or drag on the minimap to pan the main viewport to that location.
- **`minimapZoomable`** -- Scroll over the minimap to zoom the main viewport in or out.
```html
```
Both default to `false`, making the minimap view-only until explicitly enabled.
## Styling
### Node colors
Use `minimapNodeColor` to control how nodes appear in the minimap. Pass a static color string or a function for per-node colors:
```js
// Static color for all nodes
minimapNodeColor: '#6366f1'
// Per-node color based on type
minimapNodeColor: (node) => {
if (node.type === 'input') return '#22c55e';
if (node.type === 'output') return '#ef4444';
return '#6366f1';
}
```
::demo
```html
```
::enddemo
### Viewport mask
The `minimapMaskColor` option sets the color of the area outside the current viewport indicator:
```js
minimapMaskColor: 'rgba(0, 0, 0, 0.15)'
```
### CSS variables
Fine-tune minimap appearance with CSS variables:
| Variable | Description |
|----------|-------------|
| `--flow-minimap-bg` | Minimap background color |
| `--flow-minimap-border` | Border around the minimap panel |
| `--flow-minimap-node-color` | Default node fill color |
| `--flow-minimap-mask-color` | Viewport mask overlay color |
| `--flow-minimap-border-radius` | Corner rounding of the minimap panel |
# Selection
AlpineFlow supports click selection, multi-select, rectangular selection boxes, freeform lasso selection, and programmatic selection control. Selection state drives deletion, clipboard operations, group drag, and custom UI.
Click a node to select it. Shift+click to add to the selection. Shift+drag on the background to draw a selection box:
::demo
```html
```
::enddemo
## Click selection
Click a node or edge to select it. The previously selected items are deselected.
**Shift+click** toggles the clicked item in/out of the current selection without clearing it (on touch devices, two-finger tap enters selection mode — see [Touch & Mobile](../interaction/touch.md)). This works for both nodes and edges.
Selected nodes receive the `.flow-node-selected` CSS class. Selected edges receive `.flow-edge-selected`.
## Selection box
Draw a rectangle on the canvas to select multiple nodes at once.
**Default trigger:** Shift+drag on the canvas background (not on a node — on touch devices, two-finger tap enters selection mode — see [Touch & Mobile](../interaction/touch.md)).
A semi-transparent box is drawn from the drag start to the current pointer position. Nodes inside the box are selected in real-time as the box grows or shrinks. On pointer release, the box disappears and the selection is finalized.
### Selection mode
The `selectionMode` config controls containment testing:
| Mode | Behavior |
|---|---|
| `'partial'` (default) | Any overlap between the node and the box selects the node |
| `'full'` | The entire node must be inside the box |
```js
flowCanvas({
selectionMode: 'full',
})
```
**Runtime toggle:** Hold the Alt key (configurable via `keyboardShortcuts.selectionModeToggle`) during a box drag to temporarily switch to the opposite mode. When `selectionMode` is `'partial'`, holding Alt switches to `'full'` for that drag, and vice versa.
Full mode uses distinct CSS variables for visual feedback:
| Variable | Description |
|---|---|
| `--flow-selection-full-bg` | Fill color in full mode |
| `--flow-selection-full-border-color` | Border color in full mode |
| `--flow-lasso-stroke-full` | Lasso stroke in full mode |
Drag on the background to draw a selection box — nodes inside are selected:
::demo
```html
```
::enddemo
## Lasso selection
Freeform selection by drawing an arbitrary shape around nodes.
```js
flowCanvas({
selectionTool: 'lasso',
})
```
| Config | Type | Default | Description |
|---|---|---|---|
| `selectionTool` | `'box' \| 'lasso'` | `'box'` | Selection tool shape |
| `lassoSelectsEdges` | `boolean` | `false` | Whether lasso also captures edges |
When `selectionTool` is `'lasso'`, dragging on the canvas draws a freeform path instead of a rectangle. Nodes whose bounds intersect (or are fully contained by, depending on `selectionMode`) the lasso shape are selected.
### Toggle between box and lasso
Press **L** (configurable via `keyboardShortcuts.selectionToolToggle`) to toggle between box and lasso selection tools at runtime.
Draw a freeform shape around nodes to select them:
::demo
```html
```
::enddemo
## Selection on drag
By default, box/lasso selection requires the Shift modifier. For a whiteboard-style UX where left-click drag selects immediately:
```js
flowCanvas({
selectionOnDrag: true,
panOnDrag: [2], // right-click to pan instead
})
```
With `selectionOnDrag: true`, a plain left-click drag on the canvas starts a selection box (or lasso). Pair with `panOnDrag: [2]` to move panning to right-click.
## Programmatic selection
### Node and edge properties
Each node and edge has a `selected` boolean property:
```js
// Select a node
const node = $flow.getNode('node-1');
node.selected = true;
// Check if an edge is selected
const edge = $flow.getEdge('edge-1');
console.log(edge.selected); // true or false
```
### Selection sets
The canvas maintains reactive `Set` objects tracking selected IDs:
| Property | Type | Description |
|---|---|---|
| `selectedNodes` | `Set` | IDs of selected nodes |
| `selectedEdges` | `Set` | IDs of selected edges |
| `selectedRows` | `Set` | IDs of selected rows |
### `deselectAll()`
Clear all selections at once:
```js
$flow.deselectAll();
```
This sets `selected = false` on every selected node and edge, clears all three Sets, removes `.flow-node-selected` / `.flow-edge-selected` / `.flow-row-selected` CSS classes, and emits a `selection-change` event.
### Selecting via `animate()`
The `animate()` API can set selection state as an instant property:
```js
$flow.animate({
nodes: {
'node-1': { selected: true },
'node-2': { selected: false },
},
}, { duration: 0 });
```
## Events
### `selection-change`
Emitted whenever the selection changes (click, box select, lasso, programmatic, deselect).
```js
// Listen via Alpine
@selection-change.camel="handleSelection($event.detail)"
```
The event detail contains:
```ts
{
nodes: string[]; // array of selected node IDs
edges: string[]; // array of selected edge IDs
rows: string[]; // array of selected row IDs
}
```
## Keyboard shortcuts
| Key | Action |
|---|---|
| **Click** | Select node/edge (deselects others) |
| **Shift+Click** | Toggle node/edge in multi-selection |
| **Shift+Drag** | Selection box (or lasso) on canvas |
| **Tab** | Cycle keyboard focus through nodes and edges |
| **Enter / Space** | Select the focused node or edge |
| **Shift+Enter / Shift+Space** | Toggle focused item in multi-selection |
| **Delete / Backspace** | Delete selected nodes and edges |
| **Ctrl+A / Cmd+A** | Select all nodes |
| **L** | Toggle between box and lasso selection tool |
| **Alt** (held during drag) | Toggle selection mode (partial / full) |
All shortcut keys are configurable via the `keyboardShortcuts` config option.
## Z-index elevation
By default, selected nodes are elevated above unselected nodes so they render on top during multi-select drag operations. Disable with:
```js
flowCanvas({
elevateNodesOnSelect: false,
})
```
## Selection box styling
The selection box and lasso are styled via CSS variables:
| Variable | Default (theme) | Description |
|---|---|---|
| `--flow-selection-bg` | `rgba(100,116,139,0.06)` | Box fill color |
| `--flow-selection-border` | `1px solid rgba(100,116,139,0.3)` | Box border |
| `--flow-selection-border-radius` | `2px` | Box corner radius |
| `--flow-lasso-stroke` | `rgba(100,116,139,0.5)` | Lasso outline |
# Keyboard Shortcuts
AlpineFlow provides a comprehensive set of keyboard shortcuts for navigating, editing, and managing flow diagrams. All shortcuts work when the canvas or a node has focus. On touch devices, keyboard shortcuts are not available — use `x-flow-action` buttons instead (see [Touch & Mobile](../interaction/touch.md)).
Click a node, then try arrow keys to move it, Delete to remove it, or Shift+click to multi-select:
::demo
```html
```
::enddemo
## Default shortcuts
| Shortcut | Action |
|----------|--------|
| `Delete` / `Backspace` | Delete selected nodes and edges |
| `Shift` + drag | Draw a selection box |
| `Shift` + click | Toggle multi-select (add/remove from selection) |
| `Arrow keys` | Move selected nodes by 5px |
| `Shift` + arrow keys | Move selected nodes by 20px |
| `Ctrl/Cmd` + `C` | Copy selected nodes |
| `Ctrl/Cmd` + `V` | Paste copied nodes |
| `Ctrl/Cmd` + `X` | Cut selected nodes |
| `Ctrl/Cmd` + `Z` | Undo |
| `Ctrl/Cmd` + `Shift` + `Z` | Redo |
| `Escape` | Cancel current action / deselect all |
| `Alt` (hold) | Toggle selection mode during drag |
| `L` | Toggle between box and lasso selection |
| `Tab` | Focus navigation between nodes |
| `Space` (hold) | Pan mode (grab cursor) |
## Customizing
Override default key bindings through the `keyboardShortcuts` config option. Each entry maps a key code string to an action:
```html
```
Set any key to `null` to disable that particular shortcut:
```js
keyboardShortcuts: {
delete: null, // Disable delete key
cut: null, // Disable cut
}
```
Same flow, but Delete is disabled and arrow step is 20px instead of 5px:
::demo
```html
```
::enddemo
## Disabling
To disable arrow-key movement for accessibility reasons (e.g., when nodes contain focusable form inputs), set `disableKeyboardA11y: true`:
```html
```
This disables arrow-key node movement and Tab-based focus navigation while leaving other shortcuts (delete, copy/paste, undo/redo) intact.
To disable all keyboard shortcuts entirely, set each one to `null` individually in the `keyboardShortcuts` config.
# Panels
The `x-flow-panel` directive creates a draggable and resizable overlay panel anchored to a corner or edge of the flow container. Panels are commonly used for controls, minimaps, legends, or inspector sidebars.
This directive **must** be a direct child of the flow container element.
Drag the panel to reposition it, or drag its corner to resize:
::demo
```html
Inspector
Drag to move, corner to resize
```
::enddemo
## Position
The directive argument sets the initial anchor position within the flow container.
| Argument | Description |
|----------|-------------|
| `top-left` | Top-left corner |
| `top-right` | Top-right corner **(default)** |
| `bottom-left` | Bottom-left corner |
| `bottom-right` | Bottom-right corner |
| `top` | Centered along the top edge |
| `bottom` | Centered along the bottom edge |
| `left` | Centered along the left edge |
| `right` | Centered along the right edge |
## Modifiers
| Modifier | Description |
|----------|-------------|
| `.locked` | Disable dragging (resizing still allowed) |
| `.static` | Disable both dragging and resizing |
| `.no-resize` | Disable resizing (dragging still allowed) |
| `.constrained` | Clamp panel position to the viewport bounds |
| `.fill-width` | Stretch to fill the container width |
| `.fill-height` | Stretch to fill the container height |
| `.fill` | Stretch to fill both width and height |
## Behavior
- **Draggable** by default -- click and drag the panel to reposition it.
- **Resizable** by default -- a resize handle is rendered in the appropriate corner.
- Emits `panel-drag` and `panel-resize` events on the flow container when the user interacts with the panel.
- **Isolated from the canvas** -- `mousedown`, `pointerdown`, `touchstart`, `wheel` and `dblclick` stop at the panel, so a gesture aimed at what is in it does not also pan or zoom the canvas underneath. Only on the way out: the controls inside the panel still receive everything.
One exception, and it is a real one: **wheel-to-pan is not stopped**. The container's pan-on-scroll handler is registered in the capture phase, which runs before the panel's stop in the bubble phase — so under `panOnScroll`, or with shift+wheel, a wheel over a panel still pans the canvas. Wheel-to-zoom does stop.
## Usage
A controls panel anchored to the bottom-left:
```html
```
A static legend panel that cannot be moved or resized:
```html
Legend
Blue = Active
Gray = Inactive
```
A constrained, full-height inspector sidebar:
```html
```
The left panel is static (can't move or resize). The right panel is draggable but not resizable:
::demo
```html
Legend
Static — can't move
Info
Drag me — no resize
```
::enddemo
## Events
| Event | Detail | Description |
|-------|--------|-------------|
| `panel-drag` | `{ x, y }` | Fired when the panel is dragged to a new position |
| `panel-resize` | `{ width, height }` | Fired when the panel is resized |
## Styling
Customize panel appearance with CSS variables:
| Variable | Description |
|----------|-------------|
| `--flow-panel-bg` | Panel background |
| `--flow-panel-border` | Panel border |
| `--flow-panel-border-radius` | Panel corner radius |
| `--flow-panel-min-width` | Panel minimum width |
| `--flow-panel-min-height` | Panel minimum height |
| `--flow-panel-resize-bg` | Resize grip background |
| `--flow-panel-resize-border-radius` | Resize grip radius |
| `--flow-panel-resize-hover-bg` | Resize grip hover |
# Loading State
The `x-flow-loading` directive displays a loading overlay while the canvas initializes or while data is being fetched. It covers the canvas with a semi-transparent backdrop and either a built-in pulsing indicator or your own custom content.
Click "Load Data" to simulate a 2-second data fetch with a loading overlay:
::demo
```toolbar
```
```html
```
::enddemo
## Usage
Place the directive as a direct child of the flow container:
```html
...
```
If the element has no children, a default pulsing node indicator with "Loading..." text is shown. Add custom content to replace it:
```html
Fetching diagram data...
```
## Modifiers
| Modifier | Description |
|----------|-------------|
| `.fade` | Fade out smoothly (300ms) when loading completes instead of disappearing instantly |
```html
```
## Controlling Loading State
### Automatic
The overlay shows automatically during canvas initialization (`isLoading` is `true` until `ready` flips). No manual setup is needed for the initial load.
### Manual
Use `setLoading()` to control the loading state programmatically — for example, while fetching data from an API:
```js
// Show loading overlay
$flow.setLoading(true);
// Fetch data, then hide
const data = await fetch('/api/flow').then(r => r.json());
$flow.fromObject(data);
$flow.setLoading(false);
```
The `isLoading` property is `true` when either the canvas hasn't finished initializing OR the user has called `setLoading(true)`.
## Custom Content
Replace the default indicator with your own:
::demo
```toolbar
```
```html
⏳
Fetching diagram data...
```
::enddemo
## CSS Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `--flow-loading-bg` | `rgba(255, 255, 255, 0.85)` | Overlay background |
| `--flow-loading-indicator-color` | `rgba(0, 0, 0, 0.08)` | Default indicator pulse color |
| `--flow-loading-text-color` | `rgba(0, 0, 0, 0.4)` | Default indicator text color |
## See also
- [Save & Restore](../interaction/save-restore.md) -- persist and restore canvas state
- [$flow Magic > setLoading](../api/flow-magic/state-management.md) -- programmatic API
# Contextual Zoom
The `x-flow-detail` directive provides progressive disclosure — showing or hiding content within nodes based on the current zoom level. Show a summary when zoomed out and reveal details when zoomed in.
Zoom in and out (scroll wheel or pinch) to see content change:
::demo
```html
```
This is ideal for task boards, org charts, and any diagram where nodes contain varying levels of detail. At a high level, users see node titles and counts. When they zoom into a specific area, full content appears.
## Custom Thresholds
Instead of using preset modifiers, pass an expression with explicit `min` and/or `max` values:
```html
Custom range content
High zoom content
Overview-only content
```
When `min` is omitted it defaults to `0`. When `max` is omitted it defaults to `Infinity`.
Custom thresholds give you fine-grained control for specific elements without changing the global presets.
## Configuring Global Thresholds
The default threshold values (`far: 0.4`, `medium: 0.75`) come from the canvas `zoomLevels` configuration. Override them globally:
```html
```
All `x-flow-detail` directives using preset modifiers (`.far`, `.medium`, `.close`) will use the updated thresholds.
## Use Cases
| Zoom level | What to show | Example |
|------------|-------------|---------|
| **Far** (zoomed out) | Minimal — titles, badges, counts | "3 tasks", status dot |
| **Medium** | Moderate — lists, short descriptions | Subtask names, assignees |
| **Close** (zoomed in) | Full — rich content, forms, metadata | Full descriptions, edit controls, timestamps |
### Combining presets
Elements without `x-flow-detail` are always visible. Combine with detail elements to create a natural hierarchy:
```html
```
## Behavior
The directive toggles the element's `display` property. When the current zoom level falls outside the element's range, `display` is set to `none`. When inside the range, the inline `display` style is removed so the element returns to its natural display value.
This means:
- No layout shift for hidden elements — they take up zero space when hidden
- CSS transitions on `display` won't work (use `opacity` if you need animated transitions)
- The element's natural `display` value is preserved (block, flex, inline, etc.)
## See also
- [Viewport](viewport.md) — zoom controls and configuration
- [Keyboard Shortcuts](keyboard-shortcuts.md) — zoom shortcuts
# Animate & Update
AlpineFlow provides two core methods for changing node, edge, and viewport properties: `update()` for instant changes and `animate()` for smooth transitions.
::demo
```toolbar
```
```html
```
::enddemo
## update() vs animate()
Both methods accept the same `AnimateTargets` shape and `AnimateOptions`, but differ in their defaults:
| Method | Default Duration | Use Case |
|--------|-----------------|----------|
| `$flow.update()` | `0` (instant) | Snap properties to new values without visual transition |
| `$flow.animate()` | `300` ms | Smoothly interpolate properties over time |
```js
// Instant — node jumps to the new position
$flow.update({ nodes: { 'node-1': { position: { x: 300, y: 200 } } } });
// Smooth — node glides to the new position over 300ms
$flow.animate({ nodes: { 'node-1': { position: { x: 300, y: 200 } } } });
// update() with an explicit duration behaves identically to animate()
$flow.update(
{ nodes: { 'node-1': { position: { x: 300, y: 200 } } } },
{ duration: 500, easing: 'easeInOut' }
);
```
## AnimateTargets
The `targets` object has three optional keys — `nodes`, `edges`, and `viewport`:
```ts
interface AnimateTargets {
nodes?: Record;
edges?: Record;
viewport?: AnimateViewportTarget;
}
```
### Node Targets (keyed by node ID)
| Property | Type | Animated? | Description |
|---|---|---|---|
| `position` | `{ x?, y? }` | Yes | Move node to position |
| `dimensions` | `{ width?, height? }` | Yes | Resize node |
| `style` | `string \| Record` | Yes | CSS style interpolation |
> **Note:** Animating `dimensions.height` auto-sets `fixedDimensions: true` on the target node at animation start — the explicit height persists after the animation completes. Reset `fixedDimensions` to `false` on the node to let it return to content-driven sizing.
| `class` | `string` | Instant | CSS class replacement |
| `data` | `Record` | Instant | Merge into node data |
| `selected` | `boolean` | Instant | Selection state |
| `zIndex` | `number` | Instant | Z-index override |
### Edge Targets (keyed by edge ID)
| Property | Type | Animated? | Description |
|---|---|---|---|
| `color` | `string` | Yes | Stroke color interpolation |
| `strokeWidth` | `number` | Yes | Stroke width |
| `label` | `string` | Instant | Edge label text |
| `animated` | `boolean` | Instant | Dash animation toggle |
| `class` | `string` | Instant | CSS class replacement |
### Viewport Target
| Property | Type | Description |
|---|---|---|
| `pan` | `{ x?, y? }` | Pan to position |
| `zoom` | `number` | Zoom level |
## AnimateOptions
```ts
interface AnimateOptions {
// Timing
duration?: number; // ms. 0 = instant. Default: 300 (animate) / 0 (update)
easing?: EasingName | ((t: number) => number); // Default: 'easeInOut'
delay?: number; // ms before starting. Default: 0
loop?: boolean | 'ping-pong'; // true = forever, 'ping-pong' = bounce back and forth
startAt?: 'start' | 'end'; // 'end' snaps to target and plays backward. Default: 'start'.
// Physics — use instead of duration+easing
motion?: MotionConfig | string; // e.g. 'spring.wobbly' or { type: 'spring', stiffness: 100 }
maxDuration?: number; // safety cap for physics motion (ms). Default: 5000.
// Lifecycle callbacks
onStart?: () => void;
onProgress?: (progress: number) => void;
onComplete?: () => void;
// Tagging — group animations so `$flow.cancelAll({ tag })` can stop them together
tag?: string;
tags?: string[];
// State-aware cancellation — `while:` auto-cancels when the predicate returns false
while?: () => boolean;
whileStopMode?: 'jump-end' | 'rollback' | 'freeze'; // how to stop. Default: 'jump-end'.
}
```
`loop: 'reverse'` still works and is aliased to `'ping-pong'` for backwards compatibility.
Compare easing presets — all nodes move to the same target, each with a different curve:
::demo
```toolbar
```
```html
```
::enddemo
## FlowAnimationHandle
Both `update()` and `animate()` return a `FlowAnimationHandle` for controlling the in-flight animation:
```ts
interface FlowAnimationHandle {
// Transport
pause(): void;
resume(): void;
play(): void; // revive a finished handle and play from current position
playForward(): void; // set direction 'forward' and play
playBackward(): void; // set direction 'backward' and play
reverse(): void; // flip direction and keep playing
restart(options?: { direction?: 'forward' | 'backward' }): void;
stop(options?: StopOptions): void; // see "Stop modes" below
// State (all readonly)
readonly direction: 'forward' | 'backward';
readonly isFinished: boolean;
readonly currentValue: Map; // current per-key interpolated values
readonly finished: Promise;
}
interface StopOptions {
mode?: 'jump-end' | 'rollback' | 'freeze'; // default 'jump-end'
}
```
```js
const handle = $flow.animate({
nodes: {
'node-1': { position: { x: 300, y: 100 } },
'node-2': { position: { x: 500, y: 200 }, style: { opacity: '0.8' } },
},
edges: {
'edge-1': { color: '#10b981', strokeWidth: 3 },
},
}, {
duration: 500,
easing: 'easeOut',
onComplete: () => console.log('done'),
});
// Control the animation
handle.pause();
handle.resume();
```
Start a slow animation, then use the controls to pause, resume, reverse, or stop it. Buttons are disabled when they'd be a no-op (e.g. Start while running, Reverse while idle):
::demo
```toolbar
```
```html
```
::enddemo
## Stop modes
`handle.stop()` and `$flow.cancelAll(filter)` both accept a `mode` option that decides what the final visual state looks like when the animation ends:
| Mode | Behavior |
|---|---|
| `'jump-end'` *(default)* | Snap to target values — the animation completes instantly |
| `'rollback'` | Revert to the values captured when the animation started |
| `'freeze'` | Leave nodes at whatever interpolated value they happen to be at |
```js
// Snap the node to its destination
handle.stop({ mode: 'jump-end' });
// Revert to the starting values
handle.stop({ mode: 'rollback' });
// Leave it wherever it is — useful for user-cancellation UX
handle.stop({ mode: 'freeze' });
```
This matters for UX choices — "user cancelled, keep their in-progress position" is freeze; "operation failed, undo everything we changed" is rollback; "success, commit the destination" is jump-end.
Start three 4-second animations, then click "Stop all" — each one uses a different mode so you can see them side-by-side:
::demo
```toolbar
```
```html
```
::enddemo
## Transactions
A transaction wraps several animations so you can roll them all back as a unit:
```js
const tx = $flow.transaction(async () => {
$flow.animate({ nodes: { a: { position: { x: 360 } } } }, { duration: 2500 });
await new Promise(r => setTimeout(r, 500));
$flow.animate({ nodes: { b: { position: { x: 360 } } } }, { duration: 2500 });
await new Promise(r => setTimeout(r, 500));
$flow.animate({ nodes: { c: { position: { x: 360 } } } }, { duration: 2500 });
});
// Sometime later — revert everything the transaction animated
tx.rollback();
```
`tx.rollback()` stops every tracked animation with `'freeze'` mode, then re-applies the pre-transaction values for every property that was touched. The canvas ends up exactly where it was before the transaction started, even for animations that had already completed.
`tx.commit()` is the no-op counterpart — it marks the transaction as finalized and resolves `tx.finished`. Async `fn` returns commit automatically when it resolves; thrown errors auto-rollback.
Start the transaction, then click rollback mid-sequence — all three nodes freeze in place, then snap back to the origin:
::demo
```toolbar
```
```html
```
::enddemo
## Groups
Tag multiple animations with a shared name so you can stop, pause, or resume them together:
```js
const ambient = $flow.group('ambient');
ambient.animate({ nodes: { g1: { position: { x: 360 } } } }, { duration: 5000 });
ambient.animate({ nodes: { g2: { position: { x: 360 } } } }, { duration: 5000 });
ambient.animate({ nodes: { g3: { position: { x: 360 } } } }, { duration: 5000 });
// Later — stop every handle tagged 'ambient'
ambient.cancelAll({ mode: 'rollback' });
ambient.pauseAll();
ambient.resumeAll();
```
`FlowGroup` auto-tags every animation it creates, so callers never touch the `tag` option directly. You can still tag animations manually via `AnimateOptions.tag` / `.tags` and use `$flow.cancelAll({ tag: 'ambient' })` for the same effect.
::demo
```toolbar
```
```html
```
::enddemo
## State-aware cancellation
Sometimes an animation should stop when some state flips — e.g. "run this pulse while the node is hovered." Pass a `while:` predicate that the engine evaluates once per frame:
```js
let hovering = true;
$flow.animate(
{ nodes: { n: { position: { x: 360 } } } },
{
duration: 6000,
easing: 'linear',
while: () => hovering,
whileStopMode: 'freeze', // stay where the animation was when predicate flipped
},
);
// Later — flip the predicate
hovering = false;
// animation auto-terminates on the next frame with 'freeze' stop mode
```
`while:` is **terminal** — when the predicate returns false, the animation stops (with `whileStopMode`) and the handle ends. It's not a pause/resume gate; toggling the predicate back to `true` won't resume the animation. Think of it as a kill switch that fires when a condition changes.
::demo
```toolbar
active = true
```
```html
```
::enddemo
## Direction state machine
A `FlowAnimationHandle` carries a `direction` (`'forward'` or `'backward'`) that controls which way it plays. The control methods adjust both `direction` and playback state:
| Call | Effect |
|---|---|
| `handle.play()` | Revive (if finished) and play from the current value in the current `direction` |
| `handle.playForward()` | Set `direction = 'forward'` and play |
| `handle.playBackward()` | Set `direction = 'backward'` and play |
| `handle.reverse()` | Flip `direction` and keep playing |
| `handle.restart({ direction })` | Jump to the appropriate end and play in the given direction (defaults to the current one) |
```js
const handle = $flow.animate({ nodes: { n: { position: { x: 360 } } } }, { duration: 1500 });
await handle.finished;
handle.playBackward(); // replay in reverse without resetting
handle.playForward(); // play forward again from current position
handle.restart({ direction: 'backward' }); // jump to the end and play backward
```
This is the difference between `reverse()` (which always flips the current direction) and `playBackward()` (which forces backward regardless of prior direction). `restart()` is useful for "rewind and go" UX — it snaps to the start of the chosen direction before playing.
## Per-Element Timing Overrides
Individual targets can override the global duration and easing using `_duration` and `_easing`:
```js
$flow.animate({
nodes: {
'fast-node': { position: { x: 100 }, _duration: 200 },
'slow-node': { position: { x: 500 }, _duration: 1000 },
},
}, { duration: 500 }); // default for targets without _duration
```
A target with `_duration: 0` applies its changes instantly while other targets animate.
## Named Animations
Register reusable animation sequences by name, then trigger them from anywhere:
```js
// Register
$flow.registerAnimation('intro', [
{ nodes: ['a', 'b', 'c'], position: { x: 0, y: 0 }, duration: 0 },
{ nodes: ['a'], position: { x: 100, y: 50 }, duration: 500 },
{ nodes: ['b'], position: { x: 300, y: 50 }, duration: 500 },
{ nodes: ['c'], position: { x: 200, y: 200 }, duration: 500 },
]);
// Play
await $flow.playAnimation('intro');
// Unregister when no longer needed
$flow.unregisterAnimation('intro');
```
Named animations are also the mechanism behind the `x-flow-animate` directive's argument syntax:
```html
```
## x-flow-animate Directive
Trigger one-shot animations on nodes, edges, or the viewport from DOM events.
### Basic Usage
```html
```
### Step Shape
```js
{
nodes: ['id'], // target node IDs
edges: ['id'], // target edge IDs
viewport: true, // target the viewport
// Node properties
position: { x, y },
dimensions: { width, height },
style: { ... },
class: 'name',
data: { key: value },
selected: true,
zIndex: 10,
// Edge properties
color: '#ff0000',
strokeWidth: 3,
label: 'new label',
animated: true,
class: 'name',
// Viewport properties
pan: { x, y },
zoom: 1.5,
// Timing
duration: 500,
easing: 'easeInOut',
delay: 0,
}
```
### Sequential Steps
When an array of steps is provided, they execute sequentially:
```html
```
### Named Animations via Argument
Use `:name` to register a named animation that can be triggered programmatically via `playAnimation()`:
```html
```
```js
$flow.playAnimation('intro');
```
### Modifiers
| Modifier | Description |
|---|---|
| `.click` | Trigger on click (default). |
| `.mouseenter` | Trigger on mouse enter. |
| `.once` | Play the animation only once; subsequent triggers are ignored. |
| `.reverse` | Automatically reverse the animation when triggered again. |
| `.queue` | Queue the animation instead of cancelling the previous one. |
### Examples
**Mouseenter with auto-reverse** -- nodes move to the target position on hover; triggering again returns them:
```html
Hover to preview
```
**One-shot intro:**
```html
```
## Demo
Hover over each node — it animates on mouse enter and reverses on mouse leave. Shown as an imperative pattern; the `x-flow-animate` directive above is the declarative equivalent.
::demo
```html
```
::enddemo
# Physics Motion
The default `animate()` call interpolates from A to B over a fixed `duration` with an `easing` curve. For UI that needs to *feel* natural — pointer flicks, bouncing, rubber-band edges, settling springs — fixed-duration interpolation looks synthetic. Physics motion types model the motion instead of the path, so the result feels like real-world movement.
Pass a `motion:` config to `animate()` **instead of** `duration` + `easing`:
```js
// Duration-based — fixed timing, linear energy
$flow.animate({ nodes: { n: { position: { x: 400 } } } }, {
duration: 600,
easing: 'easeInOut',
});
// Physics-based — timing emerges from the motion model
$flow.animate({ nodes: { n: { position: { x: 400 } } } }, {
motion: 'spring.wobbly',
});
```
::demo
```toolbar
```
```html
```
::enddemo
Four motion types ship built-in:
| Type | When to use |
|---|---|
| `spring` | Natural settle behavior — buttons, cards, modal transitions, "snap into place" motion |
| `decay` | Deceleration from an initial impulse — pointer flicks that coast to a stop, throwable cards |
| `inertia` | Decay + bounds with bouncing and snapTo — scrollers, swipeable lists, draggable panels with limits |
| `keyframes` | Tour a node through an explicit sequence of waypoints over a fixed duration |
All four respect `maxDuration` (default 5000ms) as a safety cap — if the motion hasn't settled by then, the animation force-completes at its latest interpolated value.
## Spring
A critically-damped harmonic oscillator. The node is attached to the target via a virtual spring; it accelerates, overshoots slightly (if underdamped), and settles.
Five string presets cover the common cases:
| Preset | Stiffness | Damping | Feel |
|---|---|---|---|
| `'spring.gentle'` | 120 | 14 | Calm, minor overshoot |
| `'spring.wobbly'` | 180 | 12 | Springy, clearly visible overshoot *(default when stiffness/damping are omitted)* |
| `'spring.stiff'` | 300 | 30 | Fast, almost no overshoot — "snap into place" |
| `'spring.slow'` | 60 | 15 | Drawn-out settle |
| `'spring.molasses'` | 40 | 30 | Slow, overdamped — no overshoot |
```js
$flow.animate({ nodes: { n: { position: { x: 400 } } } }, { motion: 'spring.wobbly' });
```
Or pass a `SpringMotion` object for full control:
```js
$flow.animate({ nodes: { n: { position: { x: 400 } } } }, {
motion: {
type: 'spring',
stiffness: 200, // spring constant (higher = faster)
damping: 15, // energy loss per frame (higher = less bounce)
mass: 1, // heavier mass = slower acceleration
restVelocity: 0.01, // threshold below which motion is considered "settled"
restDisplacement: 0.01,
},
});
```
::demo
```toolbar
```
```html
```
::enddemo
## Decay
Exponential decay from an initial velocity — "throw this node and let it coast to a stop." The `target` in `animate(target, …)` is ignored for decay; the motion is driven entirely by the initial velocity and time constant.
`velocity` is the only required field. Pass a number to decay on the first axis, or `{ x, y }` for separate per-axis velocities:
```js
// Fling right at 1200 units/sec
$flow.animate({ nodes: { puck: { position: { x: 0, y: 0 } } } }, {
motion: { type: 'decay', velocity: { x: 1200, y: 0 } },
});
```
Two presets:
| Preset | Feel |
|---|---|
| `'decay.smooth'` | Gentle deceleration, longer glide |
| `'decay.snappy'` | Sharper stop, shorter glide |
Full `DecayMotion` shape:
```ts
interface DecayMotion {
type: 'decay';
velocity: number | { x: number; y: number };
power?: number; // initial velocity multiplier (default 0.8). Higher = longer glide.
timeConstant?: number; // exponential decay time in ms (default 350). Higher = slower stop.
}
```
::demo
```toolbar
```
```html
```
::enddemo
## Inertia
Decay plus **bounds** and **snap targets**. When the node hits a bound, it bounces back; when the motion settles, it can optionally snap to the nearest configured value.
```js
$flow.animate({ nodes: { ball: { position: { x: 0, y: 0 } } } }, {
motion: {
type: 'inertia',
velocity: { x: 1500, y: 200 },
bounds: { x: [0, 400], y: [0, 180] }, // bouncing walls
bounceStiffness: 300, // bounciness (normalized around 500)
bounceDamping: 30, // velocity loss per bounce, %
snapTo: [{ x: 0 }, { x: 200 }, { x: 400 }], // settle points
},
});
```
Two presets:
| Preset | Feel |
|---|---|
| `'inertia.momentum'` | Glides and decelerates — good for swipe-to-pan |
| `'inertia.rails'` | Stiff bounce off bounds, moderate damping |
Full `InertiaMotion` shape:
```ts
interface InertiaMotion {
type: 'inertia';
velocity: number | { x: number; y: number };
bounds?: Record; // [min, max] per property
bounceStiffness?: number; // default 200
bounceDamping?: number; // default 40 (percent velocity loss)
snapTo?: Array>; // settle candidates
power?: number; // default 0.8
timeConstant?: number; // default 350
}
```
`bounds` keys can be anything — not restricted to `'x'`/`'y'`. You could animate `{ scrollTop: [0, 800] }` on a scroll container, or `{ volume: [0, 100] }` on a slider.
::demo
```toolbar
```
```html
```
::enddemo
## Keyframes
Tour a node through an explicit list of waypoints over a fixed duration. Unlike spring/decay/inertia, keyframes has a defined `duration` — the timing is not emergent.
```js
$flow.animate({ nodes: { n: { position: { x: 40, y: 100 } } } }, {
motion: {
type: 'keyframes',
values: [
{ x: 40, y: 100 }, // start
{ x: 200, y: 20 }, // top middle
{ x: 360, y: 100 }, // right
{ x: 200, y: 180 }, // bottom middle
{ x: 40, y: 100 }, // back to start
],
duration: 3000, // total tour time
},
});
```
Each waypoint is a `Record` — keys match the property names you're animating. Waypoints are evenly spaced across `duration` by default; pass `offsets: [0, 0.2, 0.5, 0.8, 1]` for non-uniform timing.
Full `KeyframesMotion` shape:
```ts
interface KeyframesMotion {
type: 'keyframes';
values: Array>;
offsets?: number[]; // 0..1, one per value; default evenly spaced
between?: string | MotionConfig; // easing between waypoints (e.g. 'spring.gentle')
duration?: number; // default 5000ms
loop?: boolean | number; // replay on finish
}
```
Pass `between: 'spring.gentle'` to use spring physics to transition between each waypoint pair (instead of default linear interpolation) — useful for organic motion across the tour.
::demo
```toolbar
```
```html
```
::enddemo
## When motion vs duration+easing
Physics motion is natural-feeling but non-deterministic in timing — a spring with the same stiffness/damping will take a different amount of time to settle depending on how far it has to travel. Duration-based interpolation is the opposite: predictable timing, synthetic feel.
Rule of thumb:
- **Use physics (`motion`)** when the *feel* matters: mouse-driven flicks, cards snapping back after a drag, natural settle after user input, emergent playful effects.
- **Use duration + easing** when *timing* matters: synchronized sequences (multiple nodes arriving at the same moment), choreographed reveals, deterministic demos.
Both go through the same animator — you can mix them across animations on different nodes, or use `keyframes` to get both (a fixed-duration tour with optional per-segment springs via `between`).
## maxDuration safety cap
Physics motions terminate when velocity drops below `restVelocity` AND displacement below `restDisplacement`. A pathological config (zero damping on a spring, for example) could theoretically never settle. `maxDuration` is a safety cap — when reached, the animation force-completes at its latest interpolated value.
```js
$flow.animate(..., {
motion: 'spring.wobbly',
maxDuration: 2000, // force-settle after 2s even if still oscillating
});
```
Default is 5000ms. Lower it for time-sensitive UI; raise it for ambient background motion.
## MotionConfig type reference
```ts
type MotionConfig = SpringMotion | DecayMotion | InertiaMotion | KeyframesMotion;
interface SpringMotion {
type: 'spring';
stiffness?: number; // default 180
damping?: number; // default 12
mass?: number; // default 1
restVelocity?: number; // default 0.01
restDisplacement?: number; // default 0.01
}
interface DecayMotion {
type: 'decay';
velocity: number | { x: number; y: number };
power?: number; // default 0.8
timeConstant?: number; // default 350 (ms)
}
interface InertiaMotion {
type: 'inertia';
velocity: number | { x: number; y: number };
bounds?: Record;
bounceStiffness?: number; // default 200
bounceDamping?: number; // default 40 (percent)
snapTo?: Array>;
power?: number;
timeConstant?: number;
}
interface KeyframesMotion {
type: 'keyframes';
values: Array>;
offsets?: number[];
between?: string | MotionConfig;
duration?: number; // default 5000 (ms)
loop?: boolean | number;
}
```
Preset strings (passed as `motion: 'category.preset'`):
- **Spring:** `'spring.gentle'`, `'spring.wobbly'`, `'spring.stiff'`, `'spring.slow'`, `'spring.molasses'`
- **Decay:** `'decay.smooth'`, `'decay.snappy'`
- **Inertia:** `'inertia.momentum'`, `'inertia.rails'`
## See also
- [Animate & Update](./animate.md) — the general `animate()` / `update()` API and `AnimateOptions`
- [Path Motion](./paths.md) — move nodes along curves instead of interpolating coordinates directly
- [Timeline](./timeline.md) — sequence multiple animations; physics motion works inside timeline steps too
# Timeline
The timeline API sequences multi-step animations with parallel execution, looping, lock mode, and edge transitions. Timelines can be created programmatically via `$flow.timeline()` or declaratively with the `x-flow-timeline` directive.
::demo
```toolbar
```
```html
```
::enddemo
## Programmatic API
### Creating a Timeline
```js
const tl = $flow.timeline();
```
### Chain API
Build a timeline by chaining `step()`, `parallel()`, `loop()`, `lock()`, and `play()`:
```js
tl.step({ nodes: ['a'], position: { x: 100 }, duration: 500 })
.step({ nodes: ['b'], position: { x: 200 }, duration: 300 })
.parallel([
{ nodes: ['c'], position: { x: 300 }, duration: 400 },
{ nodes: ['d'], position: { x: 400 }, duration: 400 },
])
.loop(2) // loop 2 times (0 = infinite)
.lock() // disable user input during playback
.play();
```
Click play to see each node move in sequence:
::demo
```toolbar
```
```html
```
::enddemo
### TimelineStep Properties
```ts
interface TimelineStep> {
id?: string; // optional name for this step (shows up in events)
// Node targeting
nodes?: string[];
position?: Partial;
dimensions?: Partial;
style?: string | Record;
class?: string;
data?: Record;
selected?: boolean;
zIndex?: number;
// Path-based motion
followPath?: PathFunction | string; // SVG path string or function
guidePath?: { visible?: boolean; class?: string; autoRemove?: boolean };
// Edge targeting
edges?: string[];
edgeColor?: string;
edgeStrokeWidth?: number;
edgeClass?: string;
edgeLabel?: string;
edgeAnimated?: boolean | EdgeAnimationMode;
// Edge lifecycle
addEdges?: FlowEdge[];
removeEdges?: string[];
edgeTransition?: 'none' | 'draw' | 'fade';
// Viewport
viewport?: Partial;
fitView?: boolean;
panTo?: string; // node ID to center on
fitViewPadding?: number;
// Composition
parallel?: TimelineStep[]; // run all in parallel
timeline?: FlowTimeline; // sub-timeline — play another timeline as one step
independent?: boolean; // sub-timeline: run with its own context instead of inheriting
// Conditional
when?: (ctx: StepContext) => boolean; // run only if true
else?: TimelineStep; // alternative step when `when` returns false
// Awaitable — pause until a promise resolves (or timeout fires)
await?: Promise | { finished: Promise } | (() => Promise | { finished: Promise });
timeout?: number; // ms cap on the await
// Timing
duration?: number;
easing?: EasingName | ((t: number) => number);
delay?: number;
lock?: boolean;
// Hooks
onStart?: (ctx: StepContext) => void;
onProgress?: (progress: number, ctx: StepContext) => void;
onComplete?: (ctx: StepContext) => void;
}
```
> **Note:** Animating `dimensions.height` auto-sets `fixedDimensions: true` on the target node at animation start — the explicit height persists after the animation completes. Reset `fixedDimensions` to `false` on the node to let it return to content-driven sizing.
Steps can also be **functions** of context — `tl.step((ctx) => ({ nodes: [ctx.current], position: { x: ctx.targetX } }))` — which lets one step shape depend on data computed earlier in the timeline. See *Context* below.
### Timeline State
`tl.state` is a readonly property: `'idle'` | `'playing'` | `'paused'` | `'stopped'`.
### Timeline Events
The timeline emits events throughout its lifecycle:
| Event | Description |
|---|---|
| `play` | Playback started |
| `step` | A step began executing |
| `step-complete` | A step finished |
| `pause` | Playback paused |
| `resume` | Playback resumed |
| `interrupt` | Playback was interrupted |
| `complete` | All steps finished |
| `reverse` | Direction reversed |
| `loop` | A loop iteration completed |
| `stop` | Playback stopped |
| `reset` | Timeline was reset |
```js
tl.on('step-complete', ({ stepIndex }) => {
console.log(`Step ${stepIndex} done`);
});
```
## Context
A timeline carries a mutable **context object** that every step callback can read and write. It's how one step passes data to the next without closures.
```ts
$flow.timeline<{ winner: string }>()
.step({ nodes: ['a'], position: { x: 200 }, duration: 300,
onComplete: (ctx) => { ctx.context.winner = 'a'; } })
.step((ctx) => ({
nodes: [ctx.context.winner], // ← computed from prior step
position: { x: 400 },
duration: 300,
}))
.play();
```
The `TContext` generic parameter on `$flow.timeline()` gives you autocomplete and type-checking on `ctx.context`. Initialize it with `setContext()`:
```ts
interface MyCtx { winner: string; score: number; }
const tl = $flow.timeline().setContext({ winner: '', score: 0 });
```
`ctx` in every callback is `StepContext` — it also exposes `stepIndex`, `stepId`, and `timeline` (the parent handle).
## Conditional steps
Use `when:` to gate a step on state computed earlier, and optionally `else:` to fall back:
```js
$flow.timeline()
.step({ nodes: ['a'], position: { x: 200 }, duration: 300,
onComplete: (ctx) => { ctx.context.path = Math.random() > 0.5 ? 'left' : 'right'; } })
.step({
when: (ctx) => ctx.context.path === 'left',
nodes: ['a'], position: { x: 100, y: 100 }, duration: 400,
else: { nodes: ['a'], position: { x: 300, y: 100 }, duration: 400 },
})
.play();
```
If both `when` returns false AND `else` is absent, the step is skipped silently.
::demo
```toolbar
isPremium = true
```
```html
```
::enddemo
## Awaitable steps
Pause the timeline until an external promise resolves. The `await` value can be a bare `Promise`, an object with a `finished` promise (e.g. a `FlowAnimationHandle` or `ReplayHandle`), or a function that returns one.
```js
$flow.timeline()
.step({ nodes: ['trigger'], position: { x: 200 }, duration: 300 })
.step({
await: () => fetch('/api/ready').then(r => r.json()),
timeout: 5000, // ms — step auto-resolves on timeout to avoid stalls
})
.step({ nodes: ['result'], position: { x: 400 }, duration: 300 })
.play();
```
Good for "wait until the user confirms" or "wait for a network call" pauses mid-sequence. `timeout` is recommended — without it, a never-resolving promise stalls the whole timeline.
::demo
```toolbar
idle
```
```html
```
::enddemo
## Sub-timelines
Embed a whole timeline as a single step. Useful for reusing named sequences or grouping related steps:
```js
const intro = $flow.timeline()
.step({ nodes: ['title'], position: { y: 40 }, duration: 400 })
.step({ nodes: ['subtitle'], position: { y: 80 }, duration: 300 });
$flow.timeline()
.step({ timeline: intro }) // play the whole intro as step 1
.step({ nodes: ['cta'], position: { x: 300 }, duration: 400 })
.play();
```
By default the sub-timeline **inherits** the parent's context (shared object). Pass `independent: true` to give it a fresh isolated context:
```js
.step({ timeline: subflow, independent: true })
```
::demo
```toolbar
```
```html
```
::enddemo
## Interactive pause
`tl.pause()` can take a callback that receives a `resume` function — useful when the pause should be driven by UI (a button, a confirmation modal, a user action):
```js
tl.step({ nodes: ['a'], position: { x: 200 }, duration: 300 })
.pause((resume) => {
// called when the timeline hits this pause — show UI
document.getElementById('continue-btn').addEventListener('click', () => {
resume({ chosenPath: 'left' }); // optional: merge into context
}, { once: true });
})
.step((ctx) => ({ nodes: [ctx.context.chosenPath === 'left' ? 'a' : 'b'], position: { x: 400 } }))
.play();
```
Without a callback, `tl.pause()` just stops the timeline until something else calls `tl.play()` again.
## Edge Transitions
When adding or removing edges within a timeline step, `edgeTransition` controls the visual effect:
| Transition | Effect |
|---|---|
| `'draw'` | Path traces from source to target (or retracts on remove) |
| `'fade'` | Opacity fades in/out |
| `'none'` | Instant appear/disappear (default) |
Click play to see edges draw in, then fade out:
::demo
```toolbar
```
```html
```
::enddemo
## Lock Mode
When `lock()` is called on the timeline or a step has `lock: true`, a canvas-level `_animationLocked` flag disables all user input (drag, pan, zoom, selection, connection, keyboard) during playback. The flag is cleared during pause points and on completion.
## Undo/Redo Interaction
History capture is suspended during timeline playback. When the timeline completes (or is interrupted), the entire result is captured as a single undo entry.
## Easing Presets
| Name | Curve |
|---|---|
| `'linear'` | Constant speed |
| `'easeIn'` | Quadratic ease-in |
| `'easeOut'` | Quadratic ease-out |
| `'easeInOut'` | Quadratic ease-in-out (default) |
| `'easeBounce'` | Bounce at end |
| `'easeElastic'` | Elastic overshoot |
| `'easeBack'` | Slight overshoot in-out |
### Custom Easing Function
Pass any `(t: number) => number` function where `t` goes from 0 to 1:
```js
$flow.animate(targets, {
duration: 500,
easing: (t) => t * t * t, // cubic ease-in
});
```
## x-flow-timeline Directive
Bind a reactive timeline definition from Alpine data for declarative multi-step animations.
### Configuration Properties
| Property | Type | Default | Description |
|---|---|---|---|
| `steps` | `Array` | `[]` | Array of `step()` and `parallel()` entries. |
| `autoplay` | `boolean` | `true` | Start playback automatically when the directive initializes. |
| `loop` | `boolean \| number` | `false` | Loop playback. `true` loops indefinitely; a number sets the loop count. |
| `lock` | `boolean` | `false` | Disable user interaction with the canvas during playback. |
| `speed` | `number` | `1` | Playback speed multiplier. `2` is double speed, `0.5` is half speed. |
| `overflow` | `'queue' \| 'latest'` | `'queue'` | Behavior when a new timeline triggers while one is playing. |
| `autoFitView` | `boolean` | `false` | Automatically fit the viewport at each step. |
| `fitViewPadding` | `number` | `50` | Padding in pixels when `autoFitView` is enabled. |
| `respectReducedMotion` | `boolean` | `true` | Skip animations when the OS has reduced motion enabled. |
### Step Types
**Sequential:** steps execute one after another.
```js
steps: [
{ nodes: ['a'], position: { x: 100, y: 0 }, duration: 400 },
{ nodes: ['b'], position: { x: 200, y: 0 }, duration: 400 },
{ viewport: { zoom: 1.5 }, duration: 600 },
]
```
**Parallel:** wrap multiple steps in a `parallel` array to run at the same time.
```js
steps: [
{ parallel: [
{ nodes: ['a'], position: { x: 100, y: 0 }, duration: 400 },
{ nodes: ['b'], position: { x: 200, y: 0 }, duration: 400 },
]},
{ viewport: { zoom: 1 }, duration: 600 },
]
```
The timeline advances to the next entry only after all parallel steps complete.
Click play to see both nodes move at the same time:
::demo
```toolbar
```
```html
```
::enddemo
### Element API
The directive exposes a playback API on the host element via `el.__timeline`:
| Method / Property | Type | Description |
|---|---|---|
| `play()` | `() => void` | Start or resume playback. |
| `stop()` | `() => void` | Stop and hold at the current position. |
| `reset()` | `() => void` | Stop and reset all animated properties to initial values. |
| `state` | `'idle' \| 'playing' \| 'paused' \| 'stopped'` | Current playback state (reactive). |
```html
```
### Full Directive Example
```html
```
## Demo
A combined timeline — nodes spread out, edges draw in, then particles flow:
::demo
```toolbar
```
```html
```
::enddemo
# Path Motion
Move nodes along arbitrary curves instead of straight-line interpolation. Path functions map a progress value (0-1) to `{ x, y }` coordinates, giving you orbits, waves, SVG paths, and more.
::demo
```toolbar
```
```html
```
::enddemo
## Using with animate()
Pass a `followPath` to any node target. It overrides `position` — the node follows the curve instead of moving in a straight line:
```js
// Orbit around a point
$flow.animate({
nodes: {
'satellite': {
followPath: orbit({ cx: 200, cy: 200, radius: 100 }),
},
},
}, { duration: 3000, loop: true, easing: 'linear' });
// Wave motion with ping-pong
$flow.animate({
nodes: {
'data': {
followPath: wave({
startX: 0, startY: 100,
endX: 400, endY: 100,
amplitude: 50,
frequency: 2,
}),
},
},
}, { duration: 2000, loop: 'ping-pong' });
```
`followPath` accepts a `PathFunction` (a `(t: number) => { x, y }` function) or an SVG path `d` string.
## Using with timeline
Path functions also work in timeline steps:
```js
$flow.timeline()
.step({
nodes: ['satellite'],
followPath: orbit({ cx: 200, cy: 200, radius: 100 }),
duration: 3000,
})
.step({
nodes: ['satellite'],
position: { x: 0, y: 0 },
duration: 500,
})
.play();
```
## Built-in Path Functions
### orbit()
Circular or elliptical motion around a center point.
```js
orbit({
cx: 200, // Center X
cy: 200, // Center Y
radius: 100, // Radius (or use rx/ry for ellipse)
rx: 150, // Horizontal radius (overrides radius)
ry: 80, // Vertical radius (overrides radius)
offset: 0, // Start angle (0-1, where 1 = full rotation)
clockwise: true, // Direction
})
```
::demo
```toolbar
```
```html
```
::enddemo
### wave()
Sinusoidal oscillation along a start-end axis with perpendicular displacement.
```js
wave({
startX: 0, // Start X
startY: 100, // Start Y
endX: 400, // End X
endY: 100, // End Y
amplitude: 30, // Wave height (default: 30)
frequency: 1, // Number of full cycles (default: 1)
offset: 0, // Phase offset (0-1)
})
```
### along()
Follow an SVG path `d` string with optional reverse and subsection control.
```js
// Follow entire path
along('M 0 0 C 100 0 200 100 300 50')
// Follow path backwards, only the middle 50%
along('M 0 0 C 100 0 200 100 300 50', {
reverse: true,
startAt: 0.25,
endAt: 0.75,
})
```
You can also pass an SVG path string directly to `followPath` without wrapping in `along()`:
```js
$flow.animate({
nodes: {
'n1': { followPath: 'M 0 100 Q 200 0 400 100' },
},
}, { duration: 2000 });
```
### pendulum()
Swinging arc around a pivot point. The node hangs below the pivot.
```js
pendulum({
cx: 200, // Pivot X
cy: 50, // Pivot Y
radius: 100, // Arm length
angle: 60, // Max swing angle in degrees (default: 60)
offset: 0, // Phase offset (0-1)
})
```
### drift()
Smooth pseudo-random wandering using sine-sum noise. Great for ambient "breathing" effects.
```js
drift({
originX: 200, // Center X
originY: 200, // Center Y
range: 20, // Max displacement (default: 20)
speed: 1, // Speed multiplier (default: 1)
seed: 0, // Vary pattern per node (default: 0)
})
```
### stagger()
Distributes offset values across multiple items. Useful for staggering orbit start positions or wave phases so multiple nodes don't overlap:
```js
const offsets = stagger(0.25, { from: 0 });
// offsets(0, 4) → 0, offsets(1, 4) → 0.25, offsets(2, 4) → 0.5, offsets(3, 4) → 0.75
// Use with orbit to evenly distribute satellites
['s1', 's2', 's3', 's4'].forEach((id, i, arr) => {
$flow.animate({
nodes: { [id]: { followPath: orbit({ cx: 200, cy: 200, radius: 100, offset: offsets(i, arr.length) }) } },
}, { duration: 4000, loop: true, easing: 'linear' });
});
```
## Guide Paths
When using an SVG path string, you can show a visible guide overlay:
```js
$flow.animate({
nodes: {
'n1': {
followPath: 'M 0 100 Q 200 0 400 100',
guidePath: {
visible: true,
class: 'my-guide',
autoRemove: true, // Remove guide when animation ends (default)
},
},
},
}, { duration: 2000 });
```
Guide paths get the `.flow-guide-path` CSS class:
```css
.flow-guide-path {
stroke: var(--flow-accent);
stroke-width: 1;
stroke-dasharray: 4 4;
fill: none;
opacity: 0.4;
}
```
::demo
```toolbar
```
```html
```
::enddemo
## Custom Path Functions
Any function with the signature `(t: number) => { x: number, y: number }` works as a path:
```js
// Figure-eight
const figure8 = (t) => ({
x: 200 + 100 * Math.sin(t * Math.PI * 2),
y: 100 + 50 * Math.sin(t * Math.PI * 4),
});
$flow.animate({
nodes: { 'n1': { followPath: figure8 } },
}, { duration: 4000, loop: true, easing: 'linear' });
```
::demo
```toolbar
```
```html
```
::enddemo
## See also
- [Animate & Update](animate.md) -- core animation API
- [Timeline](timeline.md) -- multi-step sequenced animations
- [Camera Follow](camera-follow.md) -- track a moving node with the viewport
# Particles
Particles are short-lived SVG visuals that travel along a path. They power activity indicators, data-flow animations, success/error pulses, and any "something just moved from A to B" effect. A canvas-wide tick loop drives all live particles in a single `requestAnimationFrame` registration, so firing dozens concurrently is cheap.
Click "Fire" to send two sequential particles:
::demo
```toolbar
```
```html
```
::enddemo
## Firing methods
`$flow` exposes five particle-emission methods. Pick based on where the particle should travel:
| Method | Path | Returns |
|---|---|---|
| `sendParticle(edgeId, options)` | Existing edge's rendered path | `ParticleHandle` |
| `sendParticleAlongPath(svgPath, options)` | Arbitrary SVG path string | `ParticleHandle` |
| `sendParticleBetween(sourceId, targetId, options)` | Straight line between two node centers | `ParticleHandle` |
| `sendParticleBurst(edgeId, options)` | Multiple particles on one edge, staggered | `ParticleBurstHandle` |
| `sendConverging(sourceEdgeIds, options)` | Particles on many edges that arrive at a target simultaneously | `ConvergingHandle` |
### sendParticle
The most common case — fire a particle along an existing edge.
```js
const handle = $flow.sendParticle('edge-1', {
color: '#10b981',
size: 6,
duration: '1.5s',
onComplete: () => console.log('arrived'),
});
```
If the edge doesn't exist, has no rendered path yet, or has an empty `d` attribute, `sendParticle` returns `undefined` and logs a `particle` debug entry. This is a silent no-op — no throws — so it's safe to fire opportunistically (e.g., from a `$watch` that may run before layout has measured).
### sendParticleAlongPath
Fire along any SVG path string, even on a canvas with no edges. The path is parsed via a hidden `` element for `getPointAtLength` calculations and removed when the particle completes.
```js
// Arch from bottom-left to bottom-right, peaking in the middle
$flow.sendParticleAlongPath('M 60 180 Q 220 40 380 180', {
color: '#F59E0B',
duration: 1600,
});
```
::demo
```toolbar
```
```html
```
::enddemo
### sendParticleBetween
Fire a particle in a straight line between two node centers. Useful for "A messaged B" effects without needing a persistent edge.
```js
$flow.sendParticleBetween('node-a', 'node-b', {
color: '#8B5CF6',
duration: 800,
});
```
If either node is missing it returns `undefined`. The straight-line path is computed from each node's `position + dimensions/2`, so it respects measured node sizes.
### sendParticleBurst
Fire N particles on a single edge with staggered timing. Useful for "processing" indicators or emphasizing throughput.
```js
const burst = $flow.sendParticleBurst('edge-1', {
count: 5,
stagger: 120, // ms between each particle start
color: '#10b981',
size: 5,
duration: 1200,
});
await burst.finished; // resolves when all 5 arrive
```
Pass `variant(i, count)` to customize each particle individually:
```js
$flow.sendParticleBurst('edge-1', {
count: 4,
stagger: 150,
duration: 1500,
variant: (i, total) => ({
// Fade-in color over the burst
color: i === 0 ? '#ef4444' : i === total - 1 ? '#10b981' : '#f59e0b',
size: 4 + i,
}),
});
```
`ParticleBurstHandle` exposes `handles` (grows as particles fire), `finished` (resolves after all arrive), and `stopAll()` to cancel pending timers plus stop live particles.
::demo
```toolbar
```
```html
```
::enddemo
### sendConverging
Fire particles from several edges that all reach the same target node at the same time. By default (`synchronize: 'arrival'`) shorter paths get proportionally shorter durations and delayed starts so every particle lands on frame together.
```js
$flow.sendConverging(['e-src-1', 'e-src-2', 'e-src-3'], {
targetNodeId: 'sink',
duration: 1500, // the longest path takes this long
color: '#8B5CF6',
size: 5,
onAllArrived: () => {
$flow.animate({ nodes: { sink: { data: { status: 'ready' } } } });
},
});
```
Pass `synchronize: 'departure'` to fire every particle at once (they'll arrive at different times proportional to their path lengths).
::demo
```toolbar
idle
```
```html
```
::enddemo
## Built-in renderers
A renderer decides what the particle actually draws. Pick one via `renderer: 'name'`:
| Name | Shape | Best for |
|---|---|---|
| `circle` *(default)* | Filled circle | Default dot, simple data-flow effects |
| `orb` | Glowing double-circle with a pulsing scale | Attention-grabbing highlights, live status |
| `beam` | Traveling segment of the path with optional gradient | Tracers, laser-like effects, "shots fired" |
| `pulse` | Expanding ring that fades out | Ripple/activity on arrival |
| `image` | Custom SVG symbol (`#id`) or external image URL | Logos, icons, branded particles |
```js
$flow.sendParticle('e1', { renderer: 'orb', color: '#8B5CF6', size: 6 });
$flow.sendParticle('e1', { renderer: 'beam', length: 40, width: 3 });
$flow.sendParticle('e1', { renderer: 'pulse', color: '#10b981', size: 8 });
$flow.sendParticle('e1', { renderer: 'image', href: '#star-symbol', size: 20 });
```
Fire each of the built-ins plus a custom rotating "star" renderer along the same bezier edge to compare:
::demo
```toolbar
```
```html
```
::enddemo
## Beam renderer
The beam is the one renderer that reads the backing SVG path and follows its curvature. Its two unique features are **gradients** and **follow-through**.
### Path-aware curvature
Unlike `circle` or `orb` (which only care about `x, y`), the beam renders as a segment of the actual path using `stroke-dasharray`. On curves, kinked edges, and bezier paths it bends naturally instead of jutting out at corners.
```js
$flow.sendParticle('bezier-edge', {
renderer: 'beam',
length: 40, // SVG user units
width: 3, // stroke thickness
color: '#8B5CF6',
});
```
### Multi-stop gradient
Pass `gradient` as an array of color stops. `offset: 0` is the **tail** (back of the beam); `offset: 1` is the **head** (the leading edge that arrives first). When `gradient` is set, `color` is ignored.
```js
// Bright-head tracer — classic photogenic pattern
$flow.sendParticleAlongPath('M 60 180 Q 220 40 380 180', {
renderer: 'beam',
length: 80,
width: 5,
duration: 1600,
gradient: [
{ offset: 0, color: '#8B5CF6', opacity: 0 }, // transparent tail
{ offset: 0.5, color: '#D946EF', opacity: 0.6 }, // magenta rise
{ offset: 0.85, color: '#F97316', opacity: 1 }, // warm head
{ offset: 1, color: '#fff', opacity: 1 }, // bright tip
],
});
```
Each stop is `{ offset: 0..1, color: string, opacity?: number }`. Use a 2-stop gradient for a simple fading tail:
```js
// Simple fading tail
gradient: [
{ offset: 0, color: '#8B5CF6', opacity: 0 },
{ offset: 1, color: '#8B5CF6', opacity: 1 },
]
```
::demo
```toolbar
```
```html
```
::enddemo
### Follow-through
By default, the beam's tail continues past the target after the head arrives — the trail "catches up" and fades off. This looks more natural than the beam vanishing the instant the head hits its destination.
In this mode, `duration` is the **total beam lifetime** (emerge → fully exit). That means `onComplete` fires after the tail exits, not when the head arrives.
If you need `onComplete` to fire at head-arrival time (e.g., to trigger a downstream effect as the beam "hits"), opt out:
```js
$flow.sendParticle('e1', {
renderer: 'beam',
followThrough: false, // duration = head-reaches-target time
duration: 800,
onComplete: () => {
$flow.animate({ nodes: { target: { data: { hit: true } } } });
},
});
```
### Beam-specific options
| Option | Default | Effect |
|---|---|---|
| `length` | `30` | Beam length in SVG user units (how long the traveling segment is) |
| `width` | `4` | Beam thickness (`stroke-width`) |
| `color` | `#8B5CF6` | Solid stroke color (**ignored** if `gradient` is set) |
| `gradient` | — | Array of `{ offset, color, opacity? }` stops painted tail→head |
| `followThrough` | `true` | If `false`, `duration` means "head reaches target" and the beam stops the instant the head arrives |
### When the path is missing
The beam falls back to a rigid oriented rectangle if no backing `pathEl` is available on the render state. In practice this only happens if you're writing a custom renderer and want beam-like behavior in a non-path context — all five built-in firing methods provide `pathEl`.
## ParticleHandle
Every `sendParticle*` method returns a `ParticleHandle`:
```ts
interface ParticleHandle {
getCurrentPosition(): XYPosition | null; // null after completion
stop(): void;
readonly finished: Promise;
}
```
```js
const handle = $flow.sendParticle('edge-1', { duration: '3s' });
// Check position mid-flight
const pos = handle.getCurrentPosition(); // { x: 150, y: 80 }
// Wait for completion
await handle.finished;
// Or stop early
handle.stop();
```
`stopAll()` on `ParticleBurstHandle` / `ConvergingHandle` cancels pending timers AND stops all live particles.
## Options reference
All firing methods accept `ParticleOptions` (burst and converging extend it):
| Option | Type | Default | Renderer | Description |
|---|---|---|---|---|
| `renderer` | `string` | `'circle'` | — | Named renderer (`circle`, `orb`, `beam`, `pulse`, `image`, or a custom registered name) |
| `color` | `string` | `--flow-edge-dot-fill` | most | Particle color. Beam ignores this when `gradient` is set |
| `size` | `number` | `--flow-edge-dot-size` (4) | most | Radius (circle/orb/pulse) or width/height (image) in SVG user units |
| `duration` | `string \| number` | `--flow-edge-dot-duration` (2s) | all | CSS time string (`'2s'`, `'300ms'`) or numeric milliseconds |
| `speed` | `number` | — | all | SVG units per second. Overrides `duration` if both are set |
| `class` | `string` | — | all | CSS class(es) added to the particle element |
| `onComplete` | `() => void` | — | all | Fired when the particle reaches the path end (or is stopped). For `beam` with `followThrough: true` (default), fires after the tail exits |
| `length` | `number` | `30` | `beam` | Beam length in SVG user units |
| `width` | `number` | `4` | `beam` | Beam stroke thickness |
| `gradient` | `Array<{offset, color, opacity?}>` | — | `beam` | Multi-stop gradient painted tail→head |
| `followThrough` | `boolean` | `true` | `beam` | If `false`, duration means "head reaches target" |
| `href` | `string` | — | `image` | SVG symbol reference (`#my-symbol`) or external image URL |
`BurstOptions` adds `count`, `stagger`, and `variant(i, total)`. `ConvergingOptions` adds `targetNodeId`, `synchronize` (`'arrival'` or `'departure'`), and `onAllArrived`.
## Property cascade
Particle properties resolve in priority order:
1. **Call options** — explicit values passed to `sendParticle*()`
2. **Edge-level properties** — `particleSize`, `particleColor`, `animationDuration` on the edge object
3. **CSS variables** — `--flow-edge-dot-size`, `--flow-edge-dot-fill`, `--flow-edge-dot-duration`
This makes it easy to set a canvas-wide default via CSS, override per-edge when an edge has a particular personality, and force-override from code for one-off effects.
## Custom renderers
Register your own named renderer to use with `renderer: 'your-name'`. A renderer is three functions:
```ts
interface ParticleRenderer {
create: (svgLayer: SVGElement, options: ParticleOptions) => SVGElement;
update: (el: SVGElement, state: ParticleRenderState) => void;
destroy: (el: SVGElement) => void;
}
```
Called once at emission (`create`), every frame while traveling (`update`), and once at completion (`destroy`). The `state` passed to `update` gives you everything you need to position the visual:
```ts
interface ParticleRenderState {
x: number; // absolute position on the path
y: number;
progress: number; // 0..1
velocity: { x: number; y: number }; // frame-over-frame delta (use for angle)
pathLength: number; // total length of the backing path
elapsed: number; // ms since start
pathEl?: SVGPathElement; // the backing path, if any
}
```
```js
import { registerParticleRenderer } from '@getartisanflow/alpineflow';
registerParticleRenderer('star', {
create(svgLayer, options) {
const el = document.createElementNS('http://www.w3.org/2000/svg', 'path');
el.setAttribute('d', 'M 0,-8 L 2,-2 8,-2 3,1 5,8 0,4 -5,8 -3,1 -8,-2 -2,-2 Z');
el.setAttribute('fill', options.color ?? 'gold');
svgLayer.appendChild(el);
return el;
},
update(el, { x, y, elapsed }) {
const rot = (elapsed * 0.1) % 360;
el.setAttribute('transform', `translate(${x},${y}) rotate(${rot})`);
},
destroy(el) { el.remove(); },
});
// …then anywhere in your app
$flow.sendParticle('e1', { renderer: 'star', color: 'gold' });
```
The registry is global — register once at app boot, use anywhere. Returning `SVGElement` is required; the engine uses the element identity for cleanup.
## CSS styling
The `circle` renderer adds `.flow-edge-particle` by default. All renderers honor the `class` option:
```css
.flow-edge-particle {
filter: drop-shadow(0 0 3px currentColor);
}
.my-particle {
filter: drop-shadow(0 0 6px #10b981);
}
```
```js
$flow.sendParticle('e1', { class: 'my-particle' });
```
## Viewport culling
When `viewportCulling: true` is set on the canvas, particles are not emitted on edges currently hidden by culling. This prevents wasted work for off-screen edges. Particles already in flight continue rendering even if their edge scrolls out of view.
## Combining with camera follow
`$flow.follow()` accepts a `ParticleHandle` directly — the camera will track the particle, then stop when it completes.
```js
const particle = $flow.sendParticle('edge-1', { duration: '3s' });
$flow.follow(particle, { zoom: 2 });
```
See [Camera Follow](./camera-follow.md) for details.
## Continuous stream
Fire particles on a loop for ambient flowing effects. Use `setInterval` and vary color/duration for variety.
::demo
```html
```
::enddemo
# Camera Follow
Track a moving target with the viewport camera. The viewport smoothly follows via linear interpolation on each animation frame, keeping the target centered.
Click play to animate a node across the canvas — the camera follows it:
::demo
```toolbar
```
```html
```
::enddemo
## Programmatic API
```js
const handle = $flow.follow('node-1', {
zoom: 1.5, // optional target zoom level
padding: 0.1, // optional viewport padding
});
// Stop following
handle.stop();
```
Only one follow can be active at a time. Starting a new follow cancels the previous one.
## Follow Targets
The first argument accepts four target types:
| Target | Type | Behavior |
|---|---|---|
| Node ID | `string` | Centers on the node, respects `nodeOrigin` and dimensions |
| Position | `{ x, y }` | Centers on a fixed point in canvas coordinates |
| Particle handle | `ParticleHandle` | Tracks a moving particle; auto-stops when particle completes |
| Animation handle | `FlowAnimationHandle` | Tracks an animation in progress |
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `zoom` | `number` | Current zoom | Zoom level to maintain while following |
The follow loop uses a fixed lerp factor (0.08) for smoothing and does not currently expose tuning options like `speed` or `padding` — reserved for future use.
## Return Value
Returns a `FlowAnimationHandle`. In practice you'll mostly use `.stop()` (to end tracking) and `await .finished` (which resolves when the target is done). See [Animate & Update → FlowAnimationHandle](./animate.md#flowanimationhandle) for the full interface including `pause`/`resume`/`reverse`/`direction`/etc.
## Follow an Animated Node
```js
const anim = $flow.animate({
nodes: { 'node-1': { position: { x: 800, y: 400 } } },
}, { duration: 3000 });
$flow.follow(anim, { zoom: 1.5 });
```
The top demo shows this in action — the camera tracks the animation handle as the node moves.
## Follow a Particle
Fire a particle and have the camera track it along the edge path. The follow auto-stops when the particle completes:
```js
const particle = $flow.sendParticle('edge-1', { duration: '3s' });
$flow.follow(particle, { zoom: 2 });
```
::demo
```toolbar
```
```html
```
::enddemo
## Follow a Static Position
Pan the camera to a fixed point in the canvas:
```js
$flow.follow({ x: 500, y: 300 }, { zoom: 1 });
```
::demo
```toolbar
```
```html
```
::enddemo
## x-flow-follow Directive
Pan the camera to keep a node centered using a declarative directive on any clickable element.
### Usage
```html
```
### Expression
The expression accepts either a plain node ID string or an options object:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `target` | `string` | — | The node ID to follow (required when using object form) |
| `zoom` | `number` | Current zoom | Zoom level to maintain while following |
Drag a node around — the button starts/stops the camera tracking it:
::demo
```html
```
::enddemo
### `.toggle` Modifier
Without `.toggle`, clicking the button always starts following. With `.toggle`, clicking while already following the same target will stop following.
### CSS Classes
| Class | Applied To | Meaning |
|-------|-----------|---------|
| `flow-following` | The follow button element | The camera is currently following |
```css
[x-flow-follow].flow-following {
background-color: var(--flow-accent);
color: white;
}
```
# Record & Replay
`$flow.record()` captures every canvas API call made during a function — `animate`, `update`, `sendParticle*`, `addNodes`/`removeNodes`, and the viewport changes that flow through those methods. The returned `Recording` is a **serializable, time-indexable artifact** — you can replay it at any speed, scrub through it, rewind it, render thumbnails at arbitrary timestamps, save it as JSON, and play it back on a different canvas later.
This is the layer that powers session replays, demo captures, "rewind the last 5 seconds" debugging tools, and interactive scrubber UIs.
Record a sequence, then drag the slider to scrub through it:
::demo
```toolbar
0 / 0ms
```
```html
```
::enddemo
## Recording a sequence
Pass any sync or async function to `$flow.record()`. Every canvas API call made during that function — `animate`, `update`, `sendParticle*`, `addNodes`/`removeNodes`, etc. — is captured with a virtual timestamp.
```js
const recording = await $flow.record(async () => {
$flow.animate({ nodes: { n1: { position: { x: 360 } } } }, { duration: 600 });
await new Promise(r => setTimeout(r, 700));
$flow.animate({ nodes: { n1: { position: { x: 200 } } } }, { duration: 500 });
});
console.log(recording.duration); // ~1200ms
console.log(recording.events.length); // number of captured API calls
console.log(recording.checkpoints.length); // periodic canvas snapshots
```
The recording is a plain object — no live references to the canvas, no open handles. You can store it in `localStorage`, send it over the wire, or drop it into a test fixture.
### RecordOptions
```ts
interface RecordOptions {
checkpointInterval?: number; // default 500ms
captureMetadata?: Record; // attached to recording.metadata
maxDuration?: number; // safety cap, default 60000ms
}
```
- `checkpointInterval` — how often a full canvas snapshot is captured during recording. Larger intervals reduce recording size; smaller intervals enable faster scrubbing.
- `captureMetadata` — arbitrary data attached to the final `Recording.metadata`. Useful for tagging recordings (e.g., user ID, browser, experiment bucket).
- `maxDuration` — throws if the recording exceeds this. Guards against runaway loops.
### What gets captured
The recorder hooks these canvas API methods:
- **State changes:** `animate`, `update`
- **Particles:** `sendParticle`, `sendParticleAlongPath`, `sendParticleBetween`, `sendParticleBurst`, `sendConverging`
- **Structural:** `addNodes`, `removeNodes`, `addEdges`, `removeEdges`
- **Viewport:** only via `animate({ viewport: {...} })` or `update({ viewport: {...} })` — direct camera methods are a [known gap](#known-gaps).
Record a richer multi-subject sequence — moving nodes, firing beams, and changing edge colors — then replay it at the original speed:
::demo
```toolbar
no recording
```
```html
```
::enddemo
### Known gaps
The recorder hooks canvas API calls — anything that sidesteps those methods isn't captured. These are tracked as known gaps (still open as of v0.2.1-alpha):
- **User drags** — moving a node by mouse/touch writes to `node.position` directly via Alpine reactivity. The drag doesn't flow through `animate`/`update`, so the recorder doesn't see it.
- **Direct viewport methods** — `$flow.setViewport()`, `$flow.fitView()`, `$flow.zoomIn()` / `zoomOut()`, `$flow.setCenter()`, `$flow.panBy()`, `$flow.follow()`, and user-driven pan/zoom (mouse wheel, middle-drag). These talk to the pan/zoom subsystem directly. Only viewport changes made through `$flow.animate({ viewport: {...} })` or `$flow.update({ viewport: {...} })` are captured today.
- **Particle burst `variant` callbacks** — the `variant(i, total)` function in `sendParticleBurst` isn't serializable, so it's stripped during capture. On replay, every particle in the burst fires with the shared base options (count, stagger, color, size, duration) — per-index variation is lost.
The planned fix for the first two is a single "interaction sampler" — a per-frame diff of node positions and viewport that emits synthetic `update` events when anything changes outside of recorded API calls. It would be opt-in via a `captureInteractions` flag on `RecordOptions` so default behavior stays identical.
### Checkpoints and in-flight animations
Every `checkpointInterval` ms during recording, the recorder snapshots the full canvas state *and* serializes every in-flight animation's `from`/`to`/`progress`/`startTime`. When you scrub into the middle of an animation, the engine restores the checkpoint AND rehydrates those mid-flight animations so the visible state at `t = 1500` matches what the user actually saw at that moment.
This means scrubbing works correctly even if you haven't replayed from `t=0` — the engine always has enough context to reproduce the exact mid-animation state.
## Replaying
`$flow.replay(recording, options)` returns a `ReplayHandle` that plays the recording back on the current canvas.
```js
const handle = $flow.replay(recording);
await handle.finished; // resolves when playback completes
```
By default, replay starts immediately and applies the recording's `initialState` to the canvas before the first event. The same canvas API calls (`animate`, `sendParticle`, etc.) fire in order at their captured timestamps.
### ReplayOptions
```ts
interface ReplayOptions {
speed?: number; // playback speed multiplier, default 1.0
from?: number; // start at this virtual-ms, default 0
to?: number; // end at this virtual-ms, default recording.duration
loop?: boolean | number; // repeat forever or N times
paused?: boolean; // start in paused state
skipInitialState?: boolean; // leave canvas untouched at start
}
```
Common patterns:
```js
// Start paused, drive with a scrubber
const handle = $flow.replay(recording, { paused: true });
// 2x speed
$flow.replay(recording, { speed: 2.0 });
// Reverse playback
$flow.replay(recording, { speed: -1.0 });
// Loop forever for a demo reel
$flow.replay(recording, { loop: true });
// Play the last second only
$flow.replay(recording, { from: recording.duration - 1000 });
```
## Playback controls
```js
handle.play(); // begin / resume playback
handle.pause(); // stop ticking, keep current position
handle.stop(); // return to `from`, reset engine
```
`handle.speed` is both readable and settable. Negative values play backward:
```js
handle.speed = 2; // 2x fast-forward
handle.speed = -1; // reverse at real-time speed
handle.speed = 0.5; // slow-motion
```
State and direction are exposed as readable properties:
```ts
handle.state // 'idle' | 'playing' | 'paused' | 'ended'
handle.direction // 'forward' | 'backward' (computed from speed)
handle.currentTime // current virtual-ms
handle.duration // recording's total duration
```
`handle.finished` is a promise that resolves when playback reaches the end (and isn't looping). Good for `await`-driven sequences.
Record a short sequence, then play it at 1×, 2×, or in reverse:
::demo
```toolbar
```
```html
```
::enddemo
## Interactive scrubbing
`handle.scrubTo(t)` jumps to any virtual time instantly — the engine restores the nearest prior checkpoint and walks forward to `t`. This is O(log n + events-in-window), not O(full-recording).
Accepts numeric milliseconds, `'50%'` style strings, `'start'`, or `'end'`:
```js
handle.scrubTo(1500); // ms
handle.scrubTo('50%');
handle.scrubTo('end');
```
`handle.seek(t)` is an alias. See the hero demo at the top of this page for a live scrubber.
## Serializing recordings
`Recording` is JSON-safe — non-serializable values (functions, DOM refs) are stripped at capture time with a `console.warn`.
```js
const recording = await $flow.record(async () => { /* … */ });
// Persist
localStorage.setItem('demo', JSON.stringify(recording.toJSON()));
// Restore later (possibly on a different canvas, different session)
import { Recording } from '@getartisanflow/alpineflow';
const restored = Recording.fromJSON(JSON.parse(localStorage.getItem('demo')));
const handle = $flow.replay(restored);
```
`Recording.fromJSON` checks the `version` field and throws if the recording was produced by a newer AlpineFlow version than the consumer can handle.
## Introspection
A recording exposes several query methods for building custom scrubber UIs, thumbnails, and analytics:
| Method | Returns | Use for |
|---|---|---|
| `getStateAt(t)` | `CanvasSnapshot` at time `t` | Thumbnails, offline analysis |
| `getSubjects()` | `Array<{ kind, id, firstSeenT, lastSeenT }>` | Timeline gantt views — "who appeared when" |
| `getActivityFor(id)` | `Array<{ startT, endT, reason }>` | Gantt bars for a specific node/edge |
| `getValueTrack(path)` | `Array<{ t, v }>` sampled from checkpoints | Plotting a property's curve over time (e.g. `'nodes.n.position.x'`) |
| `renderThumbnailAt(t, options)` | SVG string | Static thumbnails for timeline previews |
```js
// Plot a node's x-position over the recording
const track = recording.getValueTrack('nodes.puck.position.x');
// [{ t: 0, v: 40 }, { t: 500, v: 151 }, { t: 1000, v: 342 }, …]
// Render a thumbnail at the midpoint
const svg = recording.renderThumbnailAt(recording.duration / 2, {
width: 320, height: 180,
});
```
Record a sequence, then render a strip of thumbnails at evenly-spaced times. Click a thumbnail to jump the live canvas to that moment:
::demo
```toolbar
no recording
```
```html
```
::enddemo
Thumbnails use a pluggable renderer system — `'faithful'` is the default. See the source for custom thumbnail renderers.
## Recording on a different canvas
Recordings are canvas-agnostic. A recording from one canvas can replay on another as long as the target canvas has the same nodes/edges (by `id`) that appear in the recording's `initialState`. If the recording contains `node-add` / `node-remove` events, the replay will structurally reconcile the target canvas on each frame — adding and removing elements as the recording dictates — using the target canvas's `addNodes` / `removeNodes` / `addEdges` / `removeEdges` methods if available.
## Common patterns
### Onboarding demo loop
```js
// Record once at app init, loop forever
const demo = await $flow.record(async () => {
$flow.animate({ nodes: { welcome: { position: { x: 200 } } } }, { duration: 600 });
await new Promise(r => setTimeout(r, 800));
$flow.sendParticle('e1', { renderer: 'beam', duration: 1200 });
await new Promise(r => setTimeout(r, 1400));
});
$flow.replay(demo, { loop: true });
```
### Timeline scrubber with thumbnails
```js
const recording = await $flow.record(async () => { /* session */ });
// Generate 10 thumbnails for a timeline strip
const thumbs = Array.from({ length: 10 }, (_, i) => ({
t: (i / 9) * recording.duration,
svg: recording.renderThumbnailAt(
(i / 9) * recording.duration,
{ width: 120, height: 68 },
),
}));
```
### "Rewind the last 5 seconds" debugging *(future)*
A rolling-buffer pattern — record continuously, keep the last N seconds, rewind when something breaks — is on the roadmap but **not yet supported**. It needs an explicit `recorder.stop()` (or `AbortSignal`) that lets the caller end `record()` from outside the recorded function without losing the captured events. Today `$flow.record(fn)` must see `fn` resolve naturally before the `Recording` is available, so continuous buffering isn't possible.
Tracking for a post-v0.2.0-alpha addition.
## API summary
```ts
// Capture
$flow.record(fn: () => Promise | void, options?: RecordOptions): Promise
// Playback
$flow.replay(recording: Recording, options?: ReplayOptions): ReplayHandle
// Recording (returned by record)
class Recording {
readonly version: number;
readonly duration: number;
readonly initialState: Readonly;
readonly events: ReadonlyArray;
readonly checkpoints: ReadonlyArray;
readonly metadata: Readonly>;
toJSON(): RecordingData;
static fromJSON(data: RecordingData): Recording;
getStateAt(t: number): CanvasSnapshot;
getSubjects(): Array<{ kind, id, firstSeenT, lastSeenT }>;
getActivityFor(id: string): Array<{ startT, endT, reason }>;
getValueTrack(path: string): Array<{ t, v }>;
renderThumbnailAt(t: number, options): string;
}
// ReplayHandle (returned by replay)
class ReplayHandle {
readonly recording: Recording;
readonly finished: Promise;
readonly duration: number;
readonly currentTime: number;
readonly state: 'idle' | 'playing' | 'paused' | 'ended';
readonly direction: 'forward' | 'backward';
speed: number;
play(): void;
pause(): void;
stop(): void;
scrubTo(t: number | string): void;
seek(t: number | string): void; // alias of scrubTo
getStateAt(t: number): CanvasSnapshot;
eventsUpTo(t: number): RecordingEvent[];
}
```
# Compute Flows
The compute engine propagates data through your flow graph in topological order. Register compute functions per node type, connect nodes with edges, and the engine routes outputs to inputs through handle port names.
Two number nodes feed into an adder — click "Compute" to propagate:
::demo
```toolbar
```
```html
```
::enddemo
## How it works
1. **Register** compute functions for each node type via `registerCompute()`
2. **Connect** nodes with edges — handle names route data between ports
3. **Run** `compute()` to propagate data in topological order
4. **Read** results from `node.data.$outputs` (reactive)
The engine sorts nodes topologically (sources first, sinks last) and runs each node's compute function with gathered inputs from upstream edges.
## registerCompute
Register a compute function for a node type:
```js
$flow.registerCompute('adder', {
compute(inputs, nodeData) {
return { sum: (inputs.a ?? 0) + (inputs.b ?? 0) };
}
});
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `inputs` | `Record` | Values gathered from upstream edges, keyed by target handle name |
| `nodeData` | `Record` | The node's `data` object — use for node-specific configuration |
| **returns** | `Record` | Output values keyed by source handle name |
## Port routing
Data flows through edges via **handle port names**. The edge's `sourceHandle` maps to an output key, and `targetHandle` maps to an input key:
```js
// Edge routes "value" output from num1 to "a" input on adder
{ source: 'num1', sourceHandle: 'value', target: 'adder', targetHandle: 'a' }
```
When handles are unnamed (no `sourceHandle` / `targetHandle`), the port name defaults to `'default'`:
```js
// Compute function using default port
registerCompute('passthrough', {
compute: (inputs) => ({ default: inputs.default })
});
```
## See also
- [Manual vs Auto](modes.md) -- choosing when computation runs
- [Reactive Data](reactive-data.md) -- displaying computed values in node templates
- [Configuration](../configuration/features.md#compute-engine) -- `computeMode` option
# Manual vs Auto
The compute engine supports two modes that control when data propagation runs.
## Manual mode (default)
In manual mode, computation only runs when you explicitly call `$flow.compute()`:
```js
flowCanvas({
computeMode: 'manual', // default
})
```
This gives you full control over when data propagates — useful when you want to batch changes before computing, or trigger computation from a button or event.
Click "Compute" after changing the input values:
::demo
```toolbar
```
```html
```
::enddemo
### Partial recomputation
Pass a node ID to `compute()` to only recompute that node and its downstream dependents:
```js
// Only recompute from node-3 forward
$flow.compute('node-3');
```
This skips upstream nodes that haven't changed, improving performance in large graphs.
## Auto mode
In auto mode, the engine re-propagates automatically whenever nodes or edges change (debounced at 16ms):
```js
flowCanvas({
computeMode: 'auto',
})
```
Connect new edges or add nodes — computation runs automatically:
::demo
```html
```
::enddemo
Auto mode triggers on `nodes-change` and `edges-change` events. It does **not** trigger when you modify `node.data` properties directly — call `compute()` manually for data-only changes.
## Choosing a mode
| Mode | Best for |
|------|----------|
| Manual | Forms, configuration editors, batch operations — compute on save/submit |
| Auto | Live data flow visualizations, real-time pipelines, educational tools |
## See also
- [Overview](overview.md) -- registerCompute and port routing
- [Reactive Data](reactive-data.md) -- displaying $inputs and $outputs
# Reactive Data
After `compute()` runs, each node's `data` object is updated with `$inputs` and `$outputs` properties. These are reactive — Alpine templates that reference them update automatically.
::demo
```toolbar
```
```html
```
::enddemo
## $inputs and $outputs
After each `compute()` call, the engine writes two properties to every computed node's `data`:
| Property | Type | Description |
|----------|------|-------------|
| `node.data.$inputs` | `Record` | Input values gathered from upstream edges, keyed by target handle name |
| `node.data.$outputs` | `Record` | Output values returned by the compute function, keyed by source handle name |
These are plain objects on the reactive `data` — any Alpine expression that reads them will update when computation runs.
## Using in templates
Display computed values directly in node markup:
```html
```
## Conditional rendering
Show different content based on whether computation has run:
```html
Not computed
```
## Events
The `compute-complete` event fires after each `compute()` call with the full results map:
```js
@compute-complete="console.log($event.detail.results)"
```
The `results` detail is a `Map>` — node ID to output data for every node that had a registered compute function.
## See also
- [Overview](overview.md) -- registerCompute and port routing
- [Manual vs Auto](modes.md) -- choosing when computation runs
# Building Compute Nodes
A compute node combines a visual template with a registered compute function. The template defines the ports (handles) and display, while the compute function defines the data transformation.
A constant node feeds its configured value to a formatter that outputs a string:
::demo
```toolbar
```
```html
```
::enddemo
## Anatomy of a compute node
A compute node has three parts:
1. **Node data** — configuration stored in `node.data` (constants, labels, settings)
2. **Handles** — named input/output ports defined in the template
3. **Compute function** — transforms inputs + node data into outputs
```js
// 1. Node data
{ id: 'n1', type: 'multiplier', data: { label: 'x3', factor: 3 } }
// 2. Handles in the template
// 3. Compute function
registerCompute('multiplier', {
compute(inputs, nodeData) {
return { output: (inputs.input ?? 0) * (nodeData.factor ?? 1) };
}
});
```
## Using node data for configuration
The second argument to `compute()` is the node's `data` object. Use it to make nodes configurable without changing the compute function:
```js
registerCompute('threshold', {
compute(inputs, nodeData) {
const value = inputs.value ?? 0;
const limit = nodeData.limit ?? 100;
return {
pass: value >= limit,
value: value,
};
}
});
// Two threshold nodes with different limits
{ id: 'low', type: 'threshold', data: { label: 'Low Check', limit: 10 } }
{ id: 'high', type: 'threshold', data: { label: 'High Check', limit: 100 } }
```
## Multiple input ports
Nodes can have any number of named input handles. Each handle name maps to a key in the `inputs` object:
```html
```
```js
registerCompute('mixer', {
compute(inputs) {
return {
mixed: (inputs.a ?? 0) + (inputs.b ?? 0) + (inputs.c ?? 0),
};
}
});
```
## Multiple output ports
Similarly, a node can produce multiple named outputs routed to different downstream nodes:
```html
```
```js
registerCompute('splitter', {
compute(inputs) {
const value = inputs.input ?? 0;
return {
pass: value >= 50 ? value : null,
fail: value < 50 ? value : null,
};
}
});
// Route each output to different downstream nodes
{ source: 'splitter', sourceHandle: 'pass', target: 'success-handler', targetHandle: 'input' }
{ source: 'splitter', sourceHandle: 'fail', target: 'error-handler', targetHandle: 'input' }
```
## Combining with nodeTypes
For reusable compute nodes, pair `registerCompute` with `nodeTypes` templates:
```html
```
Then use it:
```js
{ id: 'add1', type: 'adder', position: { x: 300, y: 0 }, data: { label: 'Add' } }
```
The template handles the visual layout, handles define the ports, and the compute function defines the math. All three are decoupled.
## See also
- [Overview](overview.md) -- registerCompute and port routing
- [Reactive Data](reactive-data.md) -- displaying $inputs and $outputs
- [Node Types](../nodes/basics.md#node-types) -- template registration
- [Named Handles](../handles/positions.md#multiple-handles) -- multiple handle ports
# Edge Cases
The compute engine handles several edge cases gracefully. Understanding these behaviors helps you build robust compute flows.
## Unregistered node types
Nodes without a registered compute function are **silently skipped**. They don't produce outputs and don't block downstream computation.
```js
// Only 'source' is registered — 'display' has no compute
registerCompute('source', { compute: () => ({ out: 10 }) });
// 'display' node is skipped, no error thrown
{ id: 'n1', type: 'source', ... }
{ id: 'n2', type: 'display', ... } // no compute registered
```
This is intentional — not every node type needs computation. Display-only nodes, groups, and annotation nodes can coexist in the same graph without registering compute functions.
## Missing inputs
When an input port has no incoming edge, its value is `undefined` in the `inputs` object. Always provide defaults:
```js
registerCompute('adder', {
compute(inputs) {
// Safe: defaults to 0 if not connected
return { sum: (inputs.a ?? 0) + (inputs.b ?? 0) };
}
});
```
A node with three input ports but only one connected will have two `undefined` values in `inputs`. The compute function runs regardless — it's up to you to handle missing values.
## Disconnected nodes
Nodes with no incoming edges receive an empty `inputs` object (`{}`). Source nodes (the start of a pipeline) always operate this way:
```js
registerCompute('constant', {
compute(inputs, nodeData) {
// inputs is {} — this node generates data from its own config
return { value: nodeData.value ?? 0 };
}
});
```
## Cycles
The engine uses Kahn's algorithm for topological sorting. If the graph contains cycles (A → B → A), the cycle is broken by processing the remaining nodes after the acyclic portion completes.
Cyclic nodes are appended at the end and computed with whatever inputs are available at that point. This prevents infinite loops but means cycle results may be incomplete or stale.
> **Tip:** Use `preventCycles: true` in your canvas config to prevent users from creating cyclic connections in the first place.
```js
flowCanvas({
preventCycles: true, // reject edges that would create cycles
})
```
## Compute function errors
If a compute function throws an error, that node's outputs are not written and downstream nodes receive `undefined` for that port. The engine continues processing other nodes — one failing node doesn't break the entire graph.
```js
registerCompute('risky', {
compute(inputs) {
// If this throws, downstream nodes get undefined
const result = JSON.parse(inputs.raw);
return { parsed: result };
}
});
```
For production use, wrap risky operations in try/catch within your compute function:
```js
registerCompute('safe-parser', {
compute(inputs) {
try {
return { parsed: JSON.parse(inputs.raw), error: null };
} catch (e) {
return { parsed: null, error: e.message };
}
}
});
```
## Partial recomputation scope
When calling `compute(startNodeId)`, only the start node and its downstream dependents are recomputed. Upstream nodes retain their previous `$outputs`.
If an upstream node's data changed but you only recompute from a midpoint, the upstream change won't propagate. Call `compute()` without arguments to recompute the entire graph.
```js
// Full recomputation — all nodes
$flow.compute();
// Partial — only from 'node-3' downstream
$flow.compute('node-3');
```
## Nodes added after registerCompute
Compute definitions are registered on the engine globally. Adding new nodes with an already-registered type works immediately — the next `compute()` call includes them.
In auto mode (`computeMode: 'auto'`), adding a node triggers a `nodes-change` event which automatically runs `compute()`.
## See also
- [Overview](overview.md) -- registerCompute and port routing
- [Manual vs Auto](modes.md) -- controlling when computation runs
- [Configuration](../configuration/features.md#compute-engine) -- preventCycles and computeMode
# Context Menus
The `x-flow-context-menu` directive provides right-click context menus for nodes, edges, the background pane, and multi-element selections (long-press on touch devices). Visibility, positioning, and dismissal are managed automatically.
Right-click any node or the background to try it:
::demo
```html
```
::enddemo
## Scope Modifier (Required)
One scope modifier is **required** to specify what the context menu targets.
| Modifier | Description |
|----------|-------------|
| `.node` | Context menu for individual nodes |
| `.edge` | Context menu for individual edges |
| `.pane` | Context menu for the background pane |
| `.selection` | Context menu for a multi-element selection |
## Expression: Offset Config
An optional expression provides offset adjustments for the menu position:
```html
```
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `offsetX` | `number` | `0` | Horizontal offset in pixels |
| `offsetY` | `number` | `0` | Vertical offset in pixels |
## Scoped Data: `contextMenu`
Inside the context menu element, a `contextMenu` object provides details about the right-click (or long-press) event:
| Property | Scope | Description |
|----------|-------|-------------|
| `contextMenu.node` | `.node` | The node that was right-clicked |
| `contextMenu.edge` | `.edge` | The edge that was right-clicked |
| `contextMenu.nodes` | `.selection` | The selected nodes |
| `contextMenu.edges` | `.selection` | The selected edges — an empty array where only nodes are selected |
| `contextMenu.position` | `.pane` | Where the pane was clicked, in flow coordinates |
| `contextMenu.event` | all | The original pointer event |
## Dismissal
The context menu is automatically dismissed when the user:
- Clicks anywhere outside the menu
- Scrolls the viewport
- Presses `Escape`
Call `closeContextMenu()` from within menu items to dismiss after an action.
## Keyboard Navigation
ARIA roles are applied automatically. The menu supports:
- **Arrow Up / Arrow Down** -- move between menu items
- **Tab** -- move to the next item
- **Enter** -- activate the focused item
- **Escape** -- close the menu
## Node Context Menu
Right-click a node to delete or duplicate it:
::demo
```html
```
::enddemo
## Pane Context Menu
Right-click the background to add a new node at the click position:
::demo
```html
```
::enddemo
## Edge Context Menu
Right-click the edge to delete it:
::demo
```html
```
::enddemo
# Toolbars
AlpineFlow provides two toolbar directives -- `x-flow-node-toolbar` for nodes and `x-flow-edge-toolbar` for edges. Both counter-scale against the current viewport zoom (applying `1/zoom`) so the toolbar remains readable at any zoom level.
Select a node to see its toolbar, or click the edge to see the edge toolbar:
::demo
```html
```
::enddemo
## Node Toolbar
The `x-flow-node-toolbar` directive renders a floating toolbar anchored to one side of a node. It **must** be placed inside an element with `x-flow-node`.
### Position (Argument)
The argument controls which side of the node the toolbar appears on:
| Argument | Description |
|----------|-------------|
| `:top` | Above the node **(default)** |
| `:bottom` | Below the node |
| `:left` | To the left of the node |
| `:right` | To the right of the node |
### Alignment (Modifiers)
Alignment modifiers shift the toolbar along the chosen edge:
| Modifier | Description |
|----------|-------------|
| `.start` | Align to the start (left for top/bottom, top for left/right) |
| `.center` | Center along the edge **(default)** |
| `.end` | Align to the end (right for top/bottom, bottom for left/right) |
### Offset
Use the `data-offset` attribute to set a custom gap (in pixels) between the node and the toolbar:
```html
```
### Node toolbar example
```html
```
## Edge Toolbar
The `x-flow-edge-toolbar` directive renders a floating toolbar positioned along an edge's SVG path. It **must** be placed inside an `x-flow-edge` element.
> **Important:** Edges are auto-rendered by the viewport, so there's no place to put a toolbar by default. To use edge toolbars, render edges explicitly with `x-flow-edge` inside an `x-for` loop. The toolbar goes inside the `` element and is automatically relocated to an HTML overlay.
### Explicit edge rendering
Edge toolbars require the explicit edge template pattern:
```html
...
```
The `x-flow-edge` directive handles all SVG path rendering — the `` element is just a container. The toolbar `
` is moved out of SVG into the viewport as an HTML overlay automatically.
### Path Position (Expression)
The expression accepts a number between `0` and `1` representing the position along the edge path:
| Value | Position |
|-------|----------|
| `0` | Start of the path (source node) |
| `0.5` | Midpoint of the path **(default)** |
| `1` | End of the path (target node) |
### Below Modifier
| Modifier | Description |
|----------|-------------|
| `.below` | Position the toolbar below the path instead of above it |
### Edge toolbar example
Select the edge to see a toolbar at its midpoint:
::demo
```html
```
::enddemo
# Collapse & Condense
AlpineFlow supports collapsing node hierarchies to hide descendants and condensing nodes into summary views.
Click the buttons to collapse and expand the group — child nodes are hidden when collapsed:
::demo
```toolbar
```
```html
```
::enddemo
## Collapse API
Collapse and expand nodes programmatically via `$flow`:
```js
// Collapse a node — hides all descendants
$flow.collapseNode('group-1');
// Expand a collapsed node — reveals descendants
$flow.expandNode('group-1');
// Toggle
const node = $flow.getNode('group-1');
node.collapsed ? $flow.expandNode('group-1') : $flow.collapseNode('group-1');
```
See the [Hierarchy API reference](../api/flow-magic/hierarchy.md) for all available methods.
## Collapse with x-flow-collapse directive
The `x-flow-collapse` directive adds a clickable collapse/expand toggle to a group node:
```html
```
The directive renders a toggle button that calls `collapseNode()` / `expandNode()` on click. It auto-hides on non-group nodes.
## Related
- [Nodes & Groups](../nodes/groups.md) -- parent-child relationships, batch modifiers, and full collapse/condense documentation
- [Contextual Zoom](../canvas/contextual-zoom.md) -- show/hide content within nodes based on zoom level using `x-flow-detail`
- [Hierarchy API](../api/flow-magic/hierarchy.md) -- collapseNode, expandNode, reparentNode, and more
# Drag from Sidebar
The `x-flow-draggable` directive creates a draggable element that can be dropped onto the flow canvas to add new nodes. It is typically used in a sidebar or palette outside the canvas.
Drag items from the palette onto the canvas:
::demo
```html
Input
Process
Output
```
::enddemo
## x-flow-draggable Directive
### Expression
The expression provides the data to attach to the drag event. This can be a string or an object and will be available in the canvas `onDrop` callback:
```html
Input Node
Database Node
```
## Sidebar Palette Pattern
```html
```
## Object Data
When the expression evaluates to an object, the full object is available in `onDrop`:
```html
API Call
```
```js
onDrop({ data, position }) {
// data = { type: 'api', method: 'GET', label: 'API Call' }
return {
id: crypto.randomUUID(),
position,
data,
};
}
```
Drag items with object data — the label and type are passed through:
::demo
```html
API Call
Database
```
::enddemo
## Behavior
- Sets `draggable="true"` on the element.
- On `dragstart`, serializes the expression value and attaches it to the drag event using the `application/alpineflow` MIME type.
- The flow canvas listens for `drop` events with this MIME type and invokes the `onDrop` callback with the deserialized data and the drop position in canvas coordinates.
- Drops released over a floating overlay — a `.flow-panel` (which is where an in-canvas palette lives), `.flow-controls` or `.flow-minimap` — are **cancelled**, not forwarded to `onDrop`. Those overlays sit above the canvas surface, so releasing an item back onto the palette you dragged it from reads as "never mind" rather than as a drop at the position behind it. The browser shows the "no drop" cursor while over them.
## Canvas Configuration: onDrop
The `onDrop` callback must be defined in the `flowCanvas` configuration for dropped items to create nodes:
```js
flowCanvas({
nodes: [],
edges: [],
onDrop({ event, data, position, targetNode }) {
// `event` - the native DragEvent
// `data` - the deserialized expression value from x-flow-draggable
// `position` - { x, y } in canvas coordinates
// `targetNode` - the node under the cursor, or null
return { id: 'new', position, data: { label: data } };
}
})
```
# Save & Restore
AlpineFlow provides multiple ways to save and restore canvas state: the `x-flow-snapshot` directive for declarative save/restore, the `x-flow-action:export` directive for image export, and the programmatic `toObject()` / `fromObject()` API for custom persistence.
Move the nodes around, then click Save. Move them again, then click Restore to return to the saved state:
::demo
```toolbar
```
```html
```
::enddemo
## x-flow-snapshot Directive
Declaratively save and restore the full canvas state (nodes, edges, viewport) with a single directive.
### Usage
```html
```
### Signature
| Part | Value |
|------|-------|
| Argument | **Required.** `:save` or `:restore` |
| Modifier | `.persist` -- use `localStorage` instead of in-memory storage |
| Expression | **Required.** A snapshot key string |
### `:save` Behavior
Captures the current canvas state (nodes, edges, and viewport transform) and stores it under the given key.
### `:restore` Behavior
Loads a previously saved snapshot and applies it to the canvas, replacing the current state.
### `.persist` Modifier
Without `.persist`, snapshots are held in memory and lost on page refresh. With `.persist`, snapshots are written to `localStorage` using the key format:
```
alpineflow-snapshot-{key}
```
For example, `x-flow-snapshot:save.persist="'draft'"` writes to `localStorage` key `alpineflow-snapshot-draft`.
### Auto-Disable
A restore button is **automatically disabled** when no snapshot exists for the given key. This applies to both in-memory and persisted snapshots.
## Programmatic API: toObject / fromObject
For custom persistence workflows (saving to a database, syncing with a server, etc.), use the `$flow` magic methods.
### toObject()
```ts
$flow.toObject(): { nodes: FlowNode[]; edges: FlowEdge[]; viewport: Viewport }
```
Serialize the current canvas state as a deep-cloned plain object. Suitable for saving to a database or localStorage. Emits a `save` event.
### fromObject()
```ts
$flow.fromObject(obj: {
nodes?: FlowNode[];
edges?: FlowEdge[];
viewport?: Partial;
}): void
```
Restore canvas state from a previously serialized object. Replaces the current nodes, edges, and viewport.
### Custom Persistence Example
```js
// Save to localStorage manually
const state = $flow.toObject();
localStorage.setItem('my-flow', JSON.stringify(state));
// Restore from localStorage
const saved = JSON.parse(localStorage.getItem('my-flow'));
if (saved) {
$flow.fromObject(saved);
}
```
## Export as Image
The `x-flow-action:export` directive exports the canvas as an image file:
```html
```
It accepts the same options as [`toImage`](../api/flow-magic/state-management.md#toimage), including `format` (`'png'`, `'jpeg'`, `'svg'`) and `quality`. Keep the `filename` extension in step with `format` — the option decides the bytes, not the name.
> **Note:** SVG exports are far larger than the raster formats (tens of MB on a busy canvas). That's a weakness of the underlying `html-to-image` library, which inlines every element's entire computed style instead of sharing one stylesheet — not a property of SVG itself. See the [format notes](../api/flow-magic/state-management.md#formats) before wiring one up as a download button.
Click "Export PNG" to download the canvas as an image:
::demo
```html
```
::enddemo
# Undo & Redo
AlpineFlow includes a built-in history system that tracks changes to the flow diagram and supports undo/redo via keyboard shortcuts, declarative action buttons, or the programmatic API.
Drag nodes, delete edges, then undo to restore:
::demo
```toolbar
```
```html
```
::enddemo
## Enabling History
Enable history tracking in your canvas config:
```html
```
| Option | Default | Description |
|--------|---------|-------------|
| `history` | `false` | Enable or disable history tracking |
| `historyMaxSize` | `50` | Maximum number of history entries to retain |
When the history stack exceeds `historyMaxSize`, the oldest entries are discarded.
## Keyboard Shortcuts
With history enabled, the standard keyboard shortcuts are active (desktop only — use `x-flow-action:undo` buttons for touch — see [Touch & Mobile](touch.md)):
| Shortcut | Action |
|----------|--------|
| `Ctrl/Cmd` + `Z` | Undo last action |
| `Ctrl/Cmd` + `Shift` + `Z` | Redo last undone action |
| `Ctrl` + `Y` | Redo (alternative) |
## Programmatic API
Access history methods and state through the `$flow` magic:
```js
// Undo the last action
$flow.undo()
// Redo the last undone action
$flow.redo()
// Check if undo/redo is available (reactive properties)
$flow.canUndo // boolean
$flow.canRedo // boolean
```
Example with reactive button state:
```html
```
## x-flow-action Buttons
The `x-flow-action` directive provides declarative shorthand that auto-disables the button when the action is unavailable:
```html
```
These buttons are automatically disabled when there is nothing to undo or redo, without requiring manual `:disabled` bindings. Appropriate `aria-disabled` attributes are managed for accessibility.
The undo/redo buttons auto-disable based on history state:
::demo
```html
```
::enddemo
### Other Available Actions
The `x-flow-action` directive supports additional actions beyond undo/redo:
| Action | Description |
|--------|-------------|
| `:undo` | Undo the last canvas change |
| `:redo` | Redo the last undone change |
| `:fit-view` | Pan and zoom so all nodes are visible |
| `:zoom-in` | Increase zoom by one step |
| `:zoom-out` | Decrease zoom by one step |
| `:toggle-interactive` | Toggle whether the canvas accepts user interaction |
| `:clear` | Remove all nodes and edges |
| `:reset` | Restore the canvas to its initial state |
| `:export` | Export the canvas as an image |
All action buttons are automatically disabled when their action is unavailable.
## What's Tracked
The history system records the following changes:
- **Node add/remove** -- adding or deleting nodes
- **Edge add/remove** -- adding or deleting edges
- **Node position changes** -- dragging nodes to new positions
- **Selection changes** -- selecting and deselecting nodes/edges
- **Property changes** -- modifying node or edge data, labels, styles, or other properties
Each history entry captures the full state diff, allowing precise restoration on undo/redo.
## Animation Suspension
History tracking is **automatically suspended during animation playback** (e.g., `fitView` transitions, layout animations, timeline playback). This prevents intermediate animation frames from flooding the history stack with entries that don't represent intentional user actions.
Once the animation completes, history tracking resumes and records the final state as a single entry.
## Loading Overlay
Use the `x-flow-loading` directive to show a loading overlay while restoring large saved states. See [Loading State](../canvas/loading.md) for full documentation.
## Debugging with DevTools
The `x-flow-devtools` directive provides a debug overlay with event logging, state inspection, and activity tracking -- useful for understanding history behavior during development. See the [API reference](../api/flow-magic/state-management.md) for details.
# Touch & Mobile
AlpineFlow works on touch devices out of the box. Panning, zooming (pinch), node dragging, and handle connections all use pointer events that support both mouse and touch input.
Drag nodes, pinch to zoom, and connect handles — all touch-native:
::demo
```html
```
::enddemo
## What works on touch
| Action | Touch gesture |
|--------|--------------|
| Pan viewport | One-finger drag on background |
| Zoom | Pinch with two fingers |
| Drag node | One-finger drag on node |
| Connect handles | Drag from handle dot to another |
| Select node | Tap a node |
| Context menu | Long-press (500ms) |
| Selection mode | Two-finger tap (toggle) |
## What requires a keyboard
These features are desktop-only since they rely on keyboard input:
- **Shift+click** multi-select — use touch selection mode instead (see below)
- **Delete / Backspace** to remove nodes — provide a button or context menu action
- **Arrow keys** to nudge nodes
- **Ctrl+Z / Ctrl+Y** undo/redo — provide `x-flow-action:undo` / `x-flow-action:redo` buttons
- **Ctrl+C / Ctrl+V** copy/paste
- **Drag from sidebar** — HTML5 drag-and-drop is not supported on mobile browsers
## Touch selection mode
On desktop, Shift+drag draws a selection box. On touch devices, **two-finger tap** toggles touch selection mode. While active, one-finger drag on the background draws a selection box instead of panning.
A "Selection Mode" indicator appears at the top of the canvas. Tap with two fingers again to exit.
```js
flowCanvas({
touchSelectionMode: true, // default: true
})
```
Set `touchSelectionMode: false` to disable this feature.
## Long press
Long-press (hold for 500ms) triggers context menus on touch devices — the same menus that right-click triggers on desktop.
```js
flowCanvas({
longPressAction: 'context-menu', // default
longPressDuration: 500, // ms before trigger
})
```
| Value | Behavior |
|-------|----------|
| `'context-menu'` | Long-press fires the context menu (default) |
| `'select'` | Long-press toggles multi-select on the pressed node |
| `null` | Disable long-press entirely |
## Handle hit areas
On touch screens (`@media (pointer: coarse)`), handle hit areas are automatically expanded to 44x44px — Apple's minimum recommended touch target size. The visual handle dot stays small; only the invisible tap target grows.
This is handled entirely in CSS and requires no configuration.
## Configuration tips
For a touch-optimized canvas:
```js
flowCanvas({
// Controls give touch users zoom/fit without pinch
controls: true,
// History with button UI (no keyboard shortcuts)
history: true,
// Context menus via long-press
longPressAction: 'context-menu',
// Larger snap radius for fat-finger connections
connectionSnapRadius: 30,
// Selection mode via two-finger tap
touchSelectionMode: true,
})
```
Pair with `x-flow-action` buttons for undo/redo, zoom, and fit view — these replace the keyboard shortcuts that aren't available on touch:
```html
```
## CSS variables
| Variable | Default | Description |
|----------|---------|-------------|
| `--flow-touch-selection-bg` | `rgba(59, 130, 246, 0.9)` | Selection mode indicator background |
| `--flow-touch-selection-color` | `#fff` | Selection mode indicator text color |
## See also
- [Controls Panel](../canvas/controls.md) — built-in zoom/fit buttons
- [Context Menus](context-menus.md) — right-click and long-press menus
- [Selection](../canvas/selection.md) — selection box and lasso
- [Keyboard Shortcuts](../canvas/keyboard-shortcuts.md) — desktop keyboard bindings
# Configuration
All options are passed to `flowCanvas()`. Every option has a sensible default — most flows only need `nodes` and `edges`.
```html
```
## Sections
| Section | Description |
|---------|-------------|
| [Canvas](canvas.md) | Background, zoom limits, color mode, debug |
| [Nodes](nodes.md) | Node options, data shape, type registry |
| [Edges](edges.md) | Edge options, data shape, type registry |
| [Connections](connections.md) | Drag-connect, click-to-connect, validation, multi/easy/proximity connect |
| [Viewport](viewport.md) | Pan, zoom, culling, auto-pan |
| [Interaction](interaction.md) | Selection, touch, keyboard shortcuts, accessibility |
| [Features](features.md) | History, loading, drop zone, shapes, child validation, compute, auto-layout |
| [Events](events.md) | Event callbacks, error handling, Livewire bridge |
## Data
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `nodes` | `FlowNode[]` | `[]` | Initial nodes. See [Node data shape](nodes.md#node-data-shape). |
| `edges` | `FlowEdge[]` | `[]` | Initial edges. See [Edge data shape](edges.md#edge-data-shape). |
| `viewport` | `Partial` | `{ x: 0, y: 0, zoom: 1 }` | Initial viewport position and zoom. |
# Canvas Configuration
Core options that control the canvas appearance and viewport behavior.
::demo
```html
```
::enddemo
## Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `pannable` | `boolean` | `true` | Enable viewport panning. |
| `zoomable` | `boolean` | `true` | Enable viewport zooming. |
| `interactive` | `boolean` | `true` | Master interactivity switch. Set `false` to start the canvas **locked** (no pan, zoom, or drag) without a post-ready `toggleInteractive()` call. It overrides the per-axis `pannable`/`zoomable` at init (both forced off); `toggleInteractive()` (or the controls toggle) then restores the per-axis intent. Distinct from the per-node `locked` flag, which freezes a single node. |
| `containerHeight` | `'auto' \| 'fill' \| number \| string` | `'auto'` | Canvas container height. `'auto'` = the 400px fallback, `'fill'` = 100% of the parent, a `number` = pixels, or any CSS length string (`'80vh'`). Overrides the `--flow-container-height` CSS variable. |
| `minZoom` | `number` | `0.5` | Minimum zoom level. |
| `maxZoom` | `number` | `2` | Maximum zoom level. |
| `fitViewOnInit` | `boolean` | `false` | Auto-fit all nodes in view after initialization. |
| `background` | `string \| BackgroundLayer[]` | `'dots'` | Background pattern: `'dots'`, `'lines'`, `'cross'`, `'none'`, or array of layers. |
| `backgroundGap` | `number` | `20` | Grid spacing in pixels. Overrides `--flow-bg-pattern-gap`. |
| `patternColor` | `string` | — | Pattern color. Overrides `--flow-bg-pattern-color`. Supports `rgba()`. |
| `ariaLabel` | `string` | `'Flow diagram'` | ARIA label for the container. |
| `colorMode` | `'light' \| 'dark' \| 'system'` | — | Color mode management. `'system'` tracks OS preference. Adds/removes `.dark` class. |
| `debug` | `boolean` | `false` | Enable debug logging to console. |
## See also
- [Background](../canvas/background.md)
- [Viewport](../canvas/viewport.md)
- [Theming](../theming/css-variables.md)
# Node Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `nodeOrigin` | `[number, number]` | `[0, 0]` | Default anchor point. `[0,0]` = top-left, `[0.5,0.5]` = center. Per-node `nodeOrigin` overrides. |
| `nodeExtent` | `CoordinateExtent` | — | Global position boundaries `[[minX, minY], [maxX, maxY]]`. |
| `snapToGrid` | `false \| [number, number]` | `false` | Snap node positions to grid. Pass `[gridX, gridY]` to enable. |
| `helperLines` | `boolean \| object` | `false` | Show alignment guides during drag. `true` for defaults, or `{ snap: true, threshold: 5 }`. |
| `preventOverlap` | `boolean \| number` | `false` | Prevent dragged nodes from overlapping. Pass a number for custom gap in pixels. |
| `elevateNodesOnSelect` | `boolean` | `true` | Bump z-index of selected nodes above unselected nodes. |
| `selectNodesOnDrag` | `boolean` | `true` | Automatically select nodes when drag starts. |
| `nodeDragThreshold` | `number` | `0` | Minimum pixel distance before a drag starts. |
| `nodesFocusable` | `boolean` | `true` | Allow nodes to receive keyboard focus via Tab. |
| `nodesSelectable` | `boolean` | `true` | Allow nodes to be selected by clicking, dragging or a selection box. Overridden per-node by `node.selectable`. Programmatic selection is unaffected. |
| `autoPanOnNodeDrag` | `boolean` | `true` | Auto-pan when dragging nodes near canvas edge. |
| `autoPanOnNodeFocus` | `boolean` | `false` | Auto-pan viewport when a node receives keyboard focus. |
## Node types
Register custom node types to render different templates per node:
```js
flowCanvas({
nodeTypes: {
'custom': '#my-node-template', // CSS selector for
'dynamic': (node, el) => { ... }, // Render function
},
})
```
See [Directives > flow-node](../nodes/basics.md) for template details.
## Node data shape
```js
{
id: 'node-1', // Required. Unique string ID.
position: { x: 100, y: 200 }, // Required. Flow-space coordinates.
data: { label: 'My Node' }, // Optional. Arbitrary data for templates.
type: 'default', // Optional. Maps to nodeTypes registry.
class: 'my-class', // Optional. CSS class(es) added to the node element.
style: 'background: red', // Optional. Inline styles or style object.
dimensions: { width: 200, height: 80 }, // Optional. Explicit dimensions.
fixedDimensions: false, // Optional. Opt-in to inline style.height; ResizeObserver skips node.
resizeObserver: true, // Optional. false excludes node from the shared ResizeObserver.
minDimensions: { width: 100 }, // Optional. Lower bound applied by observer (Partial).
maxDimensions: { width: 800 }, // Optional. Upper bound applied by observer (Partial).
selected: false, // Optional. Selection state.
draggable: true, // Optional. Per-node drag override.
connectable: true, // Optional. Per-node connection override.
deletable: true, // Optional. Per-node delete override.
hidden: false, // Optional. Hide from rendering.
locked: false, // Optional. Fully freeze — no drag, delete, connect, select, resize.
parentId: 'group-1', // Optional. Makes this a child of another node.
expandParent: false, // Optional. Grow parent when child reaches edge.
zIndex: 0, // Optional. Explicit z-index.
sourcePosition: 'bottom', // Optional. Default handle position for sources.
targetPosition: 'top', // Optional. Default handle position for targets.
shape: 'diamond', // Optional. Node shape variant.
rotation: 0, // Optional. Rotation angle in degrees.
nodeOrigin: [0, 0], // Optional. Per-node anchor point override.
endpointSpread: true, // Optional. boolean | { spacing? }. Fan avoidant edges that
// share one of this node's handles apart at the endpoint
// (per-node override of canvas `avoidantEndpointSpread`).
}
```
## See also
- [x-flow-node](../nodes/basics.md)
- [Shapes](../nodes/shapes.md)
- [Groups](../nodes/groups.md)
# Edge Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `defaultEdgeType` | `string` | `'bezier'` | Default edge type for all runtime-created connections (drag-connect, click-to-connect, edge-drop). Overridden per-edge by `edge.type`. |
| `defaultEdgeOptions` | `Partial` | — | Properties merged into edges created at runtime (drag-connect, click-to-connect, edge-drop). Does not affect initial edges. |
| `defaultInteractionWidth` | `number` | `20` | Invisible hit area width for edge clicks. |
| `edgesReconnectable` | `boolean` | `true` | Allow edge endpoints to be dragged to different handles. |
| `reconnectSnapRadius` | `number` | `10` | Proximity radius for endpoint snap during reconnection. |
| `edgesFocusable` | `boolean` | `true` | Allow edges to receive keyboard focus via Tab. |
| `edgesSelectable` | `boolean` | `true` | Allow edges to be selected by clicking or a selection box. Overridden per-edge by `edge.selectable`. Programmatic selection is unaffected. |
| `reconnectOnDelete` | `boolean` | `false` | Auto-bridge predecessors to successors when deleting middle nodes. |
| `avoidantSimplifyOnDrag` | `boolean` | `true` | While a node is dragged, avoidant/orthogonal edges touching it skip pathfinding and render as a plain bezier for the duration of the gesture, then re-route on drop. Set `false` to keep full pathfinding during drags. |
| `avoidantCrossingReduction` | `boolean \| { channelGap?: number }` | `false` | Reduce crossings between avoidant edges that share a corridor by fanning them into ordered lanes. `{ channelGap }` tunes the px separation. Off (the default) is byte-identical to the non-reduced route. See [Edge routing](#edge-routing). |
| `avoidantEndpointSpread` | `boolean \| { spacing?: number }` | — (off) | Fan multiple avoidant edges that share one handle apart at the endpoint. `{ spacing }` tunes the gap; never changes row height. Per-node override via `FlowNode.endpointSpread`. Off is byte-identical to spread-off. |
| `schemaHandleGeometry` | `'auto' \| 'dom'` | `'auto'` | How schema-edge endpoints are computed. `'auto'` derives them arithmetically from node position/dimensions/fields (2.3–3.8× faster, zero `getBoundingClientRect`); `'dom'` always measures the handle elements — an escape hatch for layouts whose rows aren't uniform or aren't rendered by `x-flow-schema`. Endpoints are identical either way. |
| `edgeLod` | `false \| { simplifyAt: 'far' \| 'medium' }` | `false` | Level-of-detail: simplify edge rendering when the viewport is zoomed out past the given `zoomLevels` band. |
| `collapseBidirectionalEdges` | `boolean` | `false` | Render a reciprocal pair (A→B + B→A) as a single path with a marker at each end instead of two overlapping edges. |
## Custom edge types
A generator receives the endpoint `params` and, as an optional second argument, the
`edge` itself:
```js
flowCanvas({
edgeTypes: {
'custom': ({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }, edge) => ({
path: `M ${sourceX} ${sourceY} L ${targetX} ${targetY}`,
labelPosition: { x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 },
}),
},
})
```
The `edge` argument lets a single generator read per-edge routing data straight off
the edge (e.g. precomputed waypoints stashed on `edge.data`) instead of needing a
separate closure per edge. Because that data lives on the edge, it also survives
`toObject()` / `fromObject()` serialization — a custom-routed edge reloads correctly.
The argument is optional, so existing one-parameter generators keep working unchanged.
## Edge routing
Avoidant and orthogonal edges route around node obstacles. Three opt-in / defaulted knobs tune that routing:
- **Crossing reduction** — `avoidantCrossingReduction` groups avoidant edges that funnel through the same gap and fans them into barycenter-ordered lanes, so they separate instead of drawing coincident. Enable declaratively (`true` or `{ channelGap }`) or toggle at runtime with [`$flow.setCrossingReduction(value)`](../api/flow-magic/state-management.md#setcrossingreduction). Off is byte-identical to the non-reduced route.
- **Endpoint spread** — `avoidantEndpointSpread` fans multiple edges sharing one handle apart at the endpoint (per-node override via `FlowNode.endpointSpread`). It never changes row height; at high fan-in the fan condenses within the row.
- **Drag simplification** — `avoidantSimplifyOnDrag` (default on) renders incident edges as a plain bezier during a node drag and re-routes them on drop, keeping drags smooth on dense graphs.
These are covered in the [v0.2.1-alpha migration guide](../migration/v0.2.1-alpha.md) alongside the other routing behavior shifts.
## Edge data shape
```js
{
id: 'edge-1', // Required. Unique string ID.
source: 'node-a', // Required. Source node ID.
target: 'node-b', // Required. Target node ID.
sourceHandle: 'output-1', // Optional. Source handle ID.
targetHandle: 'input-1', // Optional. Target handle ID.
type: 'bezier', // Optional. 'bezier', 'smoothstep', 'step', 'straight', 'orthogonal', 'avoidant', 'editable', or custom.
label: 'connects to', // Optional. Center label text.
labelStart: 'from', // Optional. Label near source.
labelEnd: 'to', // Optional. Label near target.
labelHtml: false, // Optional. Render the labels as HTML rather than text.
color: '#ff0000', // Optional. Stroke color string or gradient object.
strokeWidth: 2, // Optional. Stroke width.
animated: true, // Optional. true/'dash', 'pulse', or 'dot'.
markerStart: 'arrow', // Optional. Start marker: 'arrow', 'arrowclosed', or MarkerConfig.
markerEnd: 'arrowclosed', // Optional. End marker.
selected: false, // Optional. Selection state.
hidden: false, // Optional. Hide from rendering.
deletable: true, // Optional. Per-edge delete override.
class: 'my-edge', // Optional. CSS class on the SVG path.
interactionWidth: 20, // Optional. Per-edge hit area width.
}
```
## See also
- [Edges](../edges/_index.md)
- [Animation](../edges/animation.md)
# Connection Configuration
Options that control how connections are created, validated, and what advanced connection modes are available.
Drag from a source handle to connect — the snap radius highlights valid targets as you approach:
::demo
```html
```
::enddemo
## Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `isValidConnection` | `(connection: Connection) => boolean` | — | Custom validator. Called after built-in checks. Return `false` to reject. |
| `connectOnClick` | `boolean` | `true` | Click source handle, then click target handle to connect. |
| `connectionSnapRadius` | `number` | `20` | Pixel radius for snapping to nearby handles. `0` disables. |
| `connectionMode` | `'strict' \| 'loose'` | `'strict'` | `'strict'` = source→target only. `'loose'` = any handle to any handle. |
| `connectionLineType` | `string` | `'straight'` | Temp drag line path: `'bezier'`, `'smoothstep'`, `'straight'`, `'step'`. |
| `connectionLineStyle` | `object` | — | Style overrides: `{ stroke, strokeWidth, strokeDasharray }`. |
| `connectionLine` | `(props) => SVGElement` | — | Full custom renderer for the temp connection line. |
| `multiConnect` | `boolean` | `false` | Drag from one handle to create connections from ALL selected nodes' source handles. |
| `easyConnect` | `boolean` | `false` | Connect by holding a modifier key and dragging from node body. |
| `easyConnectKey` | `'alt' \| 'meta' \| 'shift'` | `'alt'` | Modifier key for easy connect. |
| `proximityConnect` | `boolean` | `false` | Auto-create edges when dragging nodes near each other. |
| `proximityConnectDistance` | `number` | `150` | Distance threshold for proximity connect. |
| `proximityConnectConfirm` | `boolean` | `false` | Show visual confirmation before proximity edge creation. |
| `onProximityConnect` | `(detail) => boolean \| void` | — | Validate or reject a proximity connection. |
| `preventCycles` | `boolean` | `false` | Reject connections that would create directed cycles. |
| `connectionRules` | `ConnectionRules` | — | Declarative type-based connection filtering. See [Connection rules](#connection-rules) below. |
| `delegatedHandleEvents` | `boolean` | `true` | Start connect/reconnect gestures from ONE delegated `pointerdown` listener per canvas instead of one per handle. See [Handle event delegation](#handle-event-delegation) below. |
## Handle event delegation
By default the canvas installs a single `pointerdown` listener that starts the
connect / reconnect gesture for every handle on it. A schema graph with thousands
of handles therefore pays for one listener, not thousands — at mount and again on
every row re-stamp.
Only `pointerdown` is delegated. Hover (`pointerenter` / `pointerleave`),
click-to-connect, and all keyboard/a11y handlers stay per-handle, so nothing about
the handle's own behaviour changes.
Set `delegatedHandleEvents: false` to restore the pre-delegation per-handle
listeners. It is read once at canvas init — patching it at runtime has no effect.
```js
flowCanvas({
delegatedHandleEvents: false, // opt out; one pointerdown listener per handle
})
```
> **Note:** under delegation the source-handle gesture stops the event during the
> capture phase, so a `pointerdown` listener you attach to your own markup *inside*
> a source handle will not fire. Put such listeners on an element outside the handle,
> or opt out with `delegatedHandleEvents: false`.
## Connection rules
`connectionRules` provides declarative type-based connection filtering. It accepts a `ConnectionRules` object with two optional properties:
```ts
interface ConnectionRules {
/** Map of source type → allowed target types. Unlisted source types are unrestricted. */
byType?: Record;
/** Function-based validator. Return false to reject. */
validate?: (connection: Connection, sourceNode: FlowNode, targetNode: FlowNode) => boolean;
}
```
```js
flowCanvas({
connectionRules: {
byType: {
trigger: ['action', 'condition'], // trigger nodes can only connect to action or condition
condition: ['action'], // condition nodes can only connect to action nodes
},
validate: (conn, source, target) => {
// Custom logic — e.g., prevent connecting to self
return source.id !== target.id;
},
},
})
```
Rules are checked before `isValidConnection`. If `byType` is set and the source node's type is listed, only the specified target types are allowed. Types not listed in `byType` are unrestricted. `validate` runs after `byType` and receives the full connection, source node, and target node.
## Connection mode
In `strict` mode (default), source handles can only connect to target handles. In `loose` mode, any handle can connect to any handle — source-to-source or target-to-target:
::demo
```html
```
::enddemo
## See also
- [Drag to Connect](../connections/drag-connect.md)
- [Click to Connect](../connections/click-to-connect.md)
- [Multi-Connect](../connections/multi-connect.md)
- [Easy Connect](../connections/easy-connect.md)
- [Proximity Connect](../connections/proximity-connect.md)
# Viewport Configuration
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `translateExtent` | `CoordinateExtent` | — | Viewport pan boundaries `[[minX, minY], [maxX, maxY]]`. |
| `viewportCulling` | `boolean \| 'auto'` | `'auto'` | Only render nodes/edges visible in the viewport. `'auto'` turns culling on once the node count reaches `cullingAutoThreshold`; `true`/`false` force it on/off. |
| `cullingAutoThreshold` | `number` | `150` | Node-count threshold at/above which `viewportCulling: 'auto'` activates culling. |
| `cullingBuffer` | `number` | `100` | Buffer in flow-space pixels around viewport for culling. |
| `panOnDrag` | `boolean \| number[]` | `true` | `true` = left button, `false` = disabled, `[0,1,2]` = specific buttons. |
| `panOnScroll` | `boolean` | `false` | Pan on mouse wheel instead of zooming. Ctrl/Cmd+wheel zooms. |
| `panOnScrollDirection` | `string` | `'both'` | `'both'`, `'vertical'`, `'horizontal'`. |
| `panOnScrollSpeed` | `number` | `1` | Scroll pan sensitivity multiplier. |
| `panActivationKeyCode` | `string \| null` | `'Space'` | Key that temporarily enables panning when held. |
| `zoomActivationKeyCode` | `string \| null` | `null` | Key that forces zoom-on-wheel, overriding `panOnScroll`. |
| `zoomOnDoubleClick` | `boolean \| 'step' \| 'toggle'` | `true` | `true`/`'step'` = d3's stepped zoom, `'toggle'` = jump-to-level and back, `false` = disabled. See [Double-click zoom](#double-click-zoom). |
| `dblClickZoomLevel` | `number` | `1.5` | Level `'toggle'` mode animates to. Clamped to `[minZoom, maxZoom]`; ignored in `'step'` mode. |
| `dblClickZoomOutLevel` | `number \| 'fit' \| 'min'` | `'min'` | Where `'toggle'` zooms out to when there is no remembered viewport. `'fit'` frames the whole graph; a number is a fixed level about the cursor. See [Double-click zoom](#double-click-zoom). |
| `zoomLevels` | `false \| object` | `{ far: 0.4, medium: 0.75 }` | Contextual zoom thresholds. Sets `data-zoom-level` attribute. See [Contextual zoom](../canvas/contextual-zoom.md). |
| `autoPanSpeed` | `number` | `15` | Auto-pan speed multiplier. |
| `autoPanOnConnect` | `boolean` | `true` | Auto-pan when drawing connections near canvas edge. |
## Double-click zoom
`zoomOnDoubleClick` picks between two gestures.
**`true` / `'step'` (default)** — d3-zoom's native handler. Each double-click multiplies the zoom by 2, `shift`+double-click divides it by 2, and both repeat until the scale extent is reached.
**`'toggle'`** — one double-click jumps to `dblClickZoomLevel` centred on the cursor and remembers where you came from; the next one puts that viewport back *exactly*. Useful when the canvas has a natural "reading" zoom and you want a one-gesture round trip to it.
```js
flowCanvas({
zoomOnDoubleClick: 'toggle',
dblClickZoomLevel: 1.5, // the "readable" level
})
```
If you reach the level some other way — the wheel, `setViewport()` — there is no remembered viewport to go back to, so a double-click there zooms out to `minZoom` about the cursor instead of doing nothing. Panning or zooming by hand discards the remembered viewport, so a later toggle-out never jumps to a view you have since left.
### Where the toggle zooms out to
That fallback is what `dblClickZoomOutLevel` sets. A remembered viewport always wins over it — the option only decides what happens when there is nothing to restore.
```js
flowCanvas({
zoomOnDoubleClick: 'toggle',
dblClickZoomLevel: 1, // double-click → 100% about the cursor
dblClickZoomOutLevel: 'fit', // double-click again → the whole graph
})
```
| Value | Second double-click goes to |
|-------|-----------------------------|
| `'min'` (default) | `minZoom`, about the cursor. |
| `'fit'` | The viewport that frames every visible node — what `fitView()` computes. Falls back to `'min'` rather than going dead when there is nothing to fit (no visible nodes, or any of them still unmeasured), and when the graph would frame at a zoom *above* the current one — a small graph fits closer than the reader already is, and a gesture that means "show me all of it" must not zoom them further in. |
| `number` | That level, about the cursor. Clamped to `[minZoom, maxZoom]`. |
`'fit'` suits a canvas people read rather than survey — a workflow, a schema — where the gesture reads as "closer" and then "show me all of it", and where `minZoom` is an arbitrary floor that frames nothing in particular.
The canvas's own chrome is not part of the gesture: a double-click on the minimap or the controls panel does what that panel does and nothing else. Two quick presses of zoom-in are two zoom steps, not a jump to the double-click level.
Two things to know about `'toggle'`:
- `dblClickZoomLevel` must sit above the level it zooms back out to (`minZoom`, or a numeric `dblClickZoomOutLevel`), otherwise there is no room to zoom back out into. If it does not, AlpineFlow keeps d3's stepped handler rather than installing a gesture that would stall.
- An out-level that is neither `'fit'` nor a finite number is read as `'min'`. AlpineFlow is configured from Blade and plain JS as often as from TypeScript, where a typo is caught by nothing, and a string in the zoom arithmetic would hand the canvas a `NaN` scale it cannot be panned back from.
- Like `'step'`, it stays live under `zoomable: false` — that flag gates pointer-gesture zooming (wheel, pinch), never double-click, so a canvas that disables wheel zoom to run its own (e.g. pinch-only via `ctrl`+wheel) keeps the double-click gesture in either mode. Disable double-click zoom itself with `zoomOnDoubleClick: false`.
**`false`** — no double-click zoom at all.
## Interaction Escape Hatches
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `noDragClassName` | `string` | `'nodrag'` | CSS class that prevents node dragging on that element. |
| `noPanClassName` | `string` | `'nopan'` | CSS class that prevents canvas panning (drag) on that element. Does not block wheel zoom — use `noWheelClassName` for that. |
| `noWheelClassName` | `string` | `'nowheel'` | CSS class that prevents wheel zoom on that element. Opt-in — no element carries it by default. |
## See also
- [Viewport](../canvas/viewport.md)
# Interaction Configuration
## Selection
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `selectionMode` | `'partial' \| 'full'` | `'partial'` | `'partial'` selects on any overlap. `'full'` requires entire node inside selection box. |
| `selectionOnDrag` | `boolean` | `false` | Start selection box on plain left-click drag (no modifier). Pair with `panOnDrag: [2]` for whiteboard UX. |
| `selectionTool` | `'box' \| 'lasso'` | `'box'` | Selection shape: rectangular box or freeform lasso. |
| `lassoSelectsEdges` | `boolean` | `false` | Whether lasso selection also selects edges. |
## Touch
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `longPressAction` | `'context-menu' \| 'select' \| null` | `'context-menu'` | Action triggered by long-press on touch devices. |
| `longPressDuration` | `number` | `500` | Duration in ms before long-press triggers. |
| `touchSelectionMode` | `boolean` | `true` | Enable two-finger-tap to toggle touch selection mode. |
## Keyboard Shortcuts
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `keyboardShortcuts` | `KeyboardShortcuts` | — | Customize or disable shortcuts. Omit a key for default, set to `null` to disable. |
| `disableKeyboardA11y` | `boolean` | `false` | Disable arrow-key movement for selected nodes. |
See [Keyboard Shortcuts](../canvas/keyboard-shortcuts.md) for the full default mapping.
## Accessibility
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `announcements` | `boolean \| object` | `true` | Screen reader announcements. `false` to disable, or `{ formatMessage }` for custom messages. |
## See also
- [Selection](../canvas/selection.md)
- [Keyboard Shortcuts](../canvas/keyboard-shortcuts.md)
# Feature Configuration
Optional features that extend the core canvas behavior. Enable them via config options.
::demo
```html
```
::enddemo
## History (Undo/Redo)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `history` | `boolean` | `false` | Enable undo/redo history tracking. |
| `historyMaxSize` | `number` | `50` | Max snapshots to retain. |
When enabled, `Ctrl+Z` / `Ctrl+Y` (or `Cmd` on Mac) trigger undo/redo. See [History](../interaction/undo-redo.md).
## Auto-Layout
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `autoLayout` | `AutoLayoutConfig` | — | Auto-layout on structural changes. See [Layout addons](../addons/dagre.md). |
## Loading
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `loading` | `boolean` | `false` | Show loading overlay until `setLoading(false)` is called. |
| `loadingText` | `string` | `'Loading…'` | Custom text for the loading indicator. |
See [x-flow-loading](../canvas/loading.md).
## Drop Zone
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `onDrop` | `(detail) => FlowNode \| null` | — | Handle drops from `x-flow-draggable` elements. Return a `FlowNode` to add it, or falsy to cancel. |
| `onEdgeDrop` | `(detail) => FlowNode \| null` | — | Handle connection drops on empty canvas. Return a `FlowNode` to auto-create and connect it. |
| `edgeDropPreview` | `(detail) => string \| HTMLElement \| null` | — | Customize the ghost node shown during edge-drop drag. |
| `dropMimeTypes` | `string[]` | — | MIME types accepted by the canvas drop zone (e.g. `['text/plain', 'application/json']`). When set, only drags carrying at least one of these types trigger `onDrop`. |
## Custom Shapes
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `shapeTypes` | `Record` | — | Custom shape definitions merged with built-ins. Each entry provides `perimeterPoint` and optional `clipPath`. |
See [Shapes](../nodes/shapes.md).
## Child Validation
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `childValidationRules` | `Record` | — | Per-type child validation rules. Keys are node type strings. |
| `onChildValidationFail` | `(detail) => void` | — | Called when validation rejects an operation. |
See [Groups](../nodes/groups.md).
## Compute Engine
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `computeMode` | `'auto' \| 'manual'` | `'manual'` | `'manual'` = explicit `$flow.compute()` calls. `'auto'` = re-propagate on changes (debounced). |
# Event Configuration
All event callbacks follow the `on{EventName}` pattern. See [Events](../api/events.md) for the full list with payload shapes.
## Event Callbacks
| Callback | Event | Description |
|----------|-------|-------------|
| `onInit` | — | Canvas fully initialized. |
| `onDestroy` | — | Canvas being destroyed. |
| `onNodeClick` | `node-click` | Node clicked. |
| `onNodeDragStart` | `node-drag-start` | Node drag started. |
| `onNodeDrag` | `node-drag` | Node being dragged (each frame). |
| `onNodeDragEnd` | `node-drag-end` | Node drag ended. |
| `onNodeResizeStart` | `node-resize-start` | Node resize started. |
| `onNodeResize` | `node-resize` | Node being resized. |
| `onNodeResizeEnd` | `node-resize-end` | Node resize ended. |
| `onEdgeClick` | `edge-click` | Edge clicked. |
| `onConnectStart` | `connect-start` | Connection drag started. |
| `onConnect` | `connect` | Connection successfully created. |
| `onMultiConnect` | `multi-connect` | Multiple connections created in one drag. |
| `onConnectEnd` | `connect-end` | Connection drag ended. |
| `onReconnectStart` | `reconnect-start` | Edge reconnection drag started. |
| `onReconnect` | `reconnect` | Edge successfully reconnected. |
| `onReconnectEnd` | `reconnect-end` | Edge reconnection drag ended. |
| `onViewportChange` | `viewport-change` | Viewport pan/zoom changed. |
| `onViewportMoveStart` | `viewport-move-start` | User gesture started. |
| `onViewportMove` | `viewport-move` | User gesture in progress. |
| `onViewportMoveEnd` | `viewport-move-end` | User gesture ended. |
| `onMinimapResize` | `minimap-resize` | Minimap drawn at a new size. Fires only when the box actually changed. |
| `onPaneClick` | `pane-click` | Canvas background clicked. |
| `onNodeContextMenu` | `node-context-menu` | Node right-clicked. |
| `onEdgeContextMenu` | `edge-context-menu` | Edge right-clicked. |
| `onPaneContextMenu` | `pane-context-menu` | Background right-clicked. |
| `onSelectionContextMenu` | `selection-context-menu` | Right-click with multi-selection. |
| `onSelectionChange` | `selection-change` | Selection changed. |
| `onNodesChange` | `nodes-change` | Nodes added or removed. |
| `onEdgesChange` | `edges-change` | Edges added or removed. |
| `onNodesPatch` | `nodes-patch` | Nodes patched. |
| `onEdgesPatch` | `edges-patch` | Edges patched. |
| `onNodeCollapse` | `node-collapse` | Node collapsed. |
| `onNodeExpand` | `node-expand` | Node expanded. |
| `onNodeCondense` | `node-condense` | Node condensed. |
| `onNodeUncondense` | `node-uncondense` | Node uncondensed. |
| `onBeforeDelete` | — | Before user-initiated deletion. Return subset to delete or `false` to cancel. Async. |
Every callback receives the event `detail` as its first argument and the **canvas context** (the same object exposed as `$flow`) as an optional second — `onConnect(detail, ctx)`, `onDrop(detail, ctx)`, etc. — so a handler can call `ctx.addNodes(...)` / `ctx.fitView()` without a global reference. The context is passed as an argument only, never added to `detail`. Handlers written as `(detail) => …` are unaffected.
## Error Handling
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `onError` | `(code, message) => void` | — | Global error/warning handler. Routes internal warnings through this callback. |
## Livewire Bridge
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `wireEvents` | `Record` | — | Map AlpineFlow events to Livewire method names. Set automatically by WireFlow's Blade component. |
# Events
AlpineFlow emits events for every significant interaction, structural change, and lifecycle transition. Events can be consumed in three ways.
Click nodes, drag them, create connections — the devtools panel logs every event:
::demo
```html
```
::enddemo
## Listening to Events
### 1. Config Callbacks
Pass callback functions in the `flowCanvas()` configuration. The callback name follows the pattern `on` + PascalCase event name.
```html
```
Every config callback also receives the **canvas context** as an optional second argument, so a handler can drive the canvas without reaching for a global reference. It is the same object exposed as `$flow` inside the canvas element:
```js
flowCanvas({
onDrop: (detail, ctx) => ctx.addNodes(makeNodeFrom(detail)),
onConnect: (detail, ctx) => ctx.fitView(),
})
```
The context is passed as a second argument only — it is **never** added to the event `detail`, which stays a plain, serializable object for the DOM `CustomEvent` dispatch (and WireFlow). Existing handlers that take only `(detail)` are unaffected.
### 2. Alpine Event Directives
Events are dispatched as DOM `CustomEvent`s on the container with a `flow-` prefix. Use Alpine's `@` directive to listen.
```html
```
Events bubble, so you can also listen on parent elements or `window`.
### 3. Direct DOM Listeners
For vanilla JavaScript integration:
```js
document.querySelector('[data-flow-canvas]')
.addEventListener('flow-node-click', (e) => {
console.log('Clicked:', e.detail.node.id);
});
```
---
## Node Interaction Events
### node-click
Fired when a node is clicked.
```ts
{ node: FlowNode; event: MouseEvent }
```
Config callback: `onNodeClick`
### node-drag-start
Fired when a node drag operation begins.
```ts
{ node: FlowNode }
```
Config callback: `onNodeDragStart`
### node-drag
Fired continuously during a node drag.
```ts
{ node: FlowNode; position: XYPosition }
```
Config callback: `onNodeDrag`
### node-drag-end
Fired when a node drag operation ends.
```ts
{ node: FlowNode; position: XYPosition }
```
Config callback: `onNodeDragEnd`
### node-resize-start
Fired when a node resize begins (via `x-flow-resizer`).
```ts
{ node: FlowNode; dimensions: Dimensions }
```
Config callback: `onNodeResizeStart`
### node-resize
Fired continuously during a node resize.
```ts
{ node: FlowNode; dimensions: Dimensions }
```
Config callback: `onNodeResize`
### node-resize-end
Fired when a node resize ends.
```ts
{ node: FlowNode; dimensions: Dimensions }
```
Config callback: `onNodeResizeEnd`
### node-context-menu
Fired when a node is right-clicked.
```ts
{ node: FlowNode; event: MouseEvent }
```
Config callback: `onNodeContextMenu`
### node-collapse
Fired when a node is collapsed (descendants hidden).
```ts
{ node: FlowNode; descendants: string[] }
```
Config callback: `onNodeCollapse`
### node-expand
Fired when a node is expanded (descendants restored).
```ts
{ node: FlowNode; descendants: string[] }
```
Config callback: `onNodeExpand`
### node-condense
Fired when a node switches to condensed (summary) view.
```ts
{ node: FlowNode }
```
Config callback: `onNodeCondense`
### node-uncondense
Fired when a node restores full row view.
```ts
{ node: FlowNode }
```
Config callback: `onNodeUncondense`
---
## Edge Interaction Events
### edge-click
Fired when an edge is clicked.
```ts
{ edge: FlowEdge; event: MouseEvent }
```
Config callback: `onEdgeClick`
### edge-context-menu
Fired when an edge is right-clicked.
```ts
{ edge: FlowEdge; event: MouseEvent }
```
Config callback: `onEdgeContextMenu`
---
## Connection Events
### connect-start
Fired when a connection drag begins from a source handle.
```ts
{ source: string; sourceHandle?: string }
```
Config callback: `onConnectStart`
### connect
Fired when a connection is successfully created (single connection).
```ts
{ connection: Connection }
```
Where `Connection` is `{ source: string; sourceHandle?: string; target: string; targetHandle?: string }`.
Config callback: `onConnect`
### multi-connect
Fired when multiple connections are created in a single multi-connect drag.
```ts
{ connections: Connection[] }
```
Config callback: `onMultiConnect`
### connect-end
Fired when a connection drag ends (whether successful or cancelled).
```ts
{
connection: Connection | null;
source: string;
sourceHandle?: string;
position: XYPosition;
}
```
`connection` is `null` if the drag was cancelled without creating an edge.
Config callback: `onConnectEnd`
---
## Reconnection Events
### reconnect-start
Fired when an edge endpoint reconnection drag begins.
```ts
{ edge: FlowEdge; handleType: HandleType }
```
Where `HandleType` is `'source' | 'target'`.
Config callback: `onReconnectStart`
### reconnect
Fired when an edge is successfully reconnected to a new handle.
```ts
{ oldEdge: FlowEdge; newConnection: Connection }
```
Config callback: `onReconnect`
### reconnect-end
Fired when an edge reconnection drag ends.
```ts
{ edge: FlowEdge; successful: boolean }
```
Config callback: `onReconnectEnd`
---
## Viewport Events
### viewport-change
Fired whenever the viewport state changes (any pan or zoom).
```ts
{ viewport: Viewport }
```
Config callback: `onViewportChange`
### viewport-move-start
Fired when a user gesture (pan/zoom) starts.
```ts
{ viewport: Viewport }
```
Config callback: `onViewportMoveStart`
### viewport-move
Fired each frame during a user gesture (pan/zoom).
```ts
{ viewport: Viewport }
```
Config callback: `onViewportMove`
### viewport-move-end
Fired when a user gesture (pan/zoom) ends.
```ts
{ viewport: Viewport }
```
Config callback: `onViewportMoveEnd`
---
## Canvas Events
### pane-click
Fired when the canvas background (empty space) is clicked.
```ts
{ event: MouseEvent; position: XYPosition }
```
`position` is in flow coordinates.
Config callback: `onPaneClick`
### pane-context-menu
Fired when the canvas background is right-clicked.
```ts
{ event: MouseEvent; position: XYPosition }
```
Config callback: `onPaneContextMenu`
---
## Selection Events
### selection-change
Fired whenever the set of selected nodes, edges, or rows changes.
```ts
{
nodes: string[]; // selected node IDs
edges: string[]; // selected edge IDs
rows: string[]; // selected row IDs
}
```
Config callback: `onSelectionChange`
### selection-context-menu
Fired when opening a context menu with multiple nodes selected — a right-click on the pane, a
right-click on one of the selected nodes, or a long press on a touch device.
```ts
{ nodes: FlowNode[]; edges: FlowEdge[]; event: MouseEvent }
```
`edges` carries the selected edges, which is what lets a menu item act on the whole selection
rather than on its nodes alone. It is an empty array when only nodes are selected.
Config callback: `onSelectionContextMenu`
---
## Structure Events
### nodes-change
Fired when nodes are added or removed.
```ts
{ type: 'add' | 'remove'; nodes: FlowNode[]; origin: 'drop' | 'paste' | 'api' | 'load' }
```
`origin` tells you what caused the change so you can react only to user intent — `'drop'` (drag-drop), `'paste'` (clipboard), `'load'` (bulk `fromObject`/`replaceNodes`), or `'api'` (a direct `addNodes`/`removeNodes` call, the default). The mutators accept a `{ source }` option to override it: `$flow.addNodes(nodes, { source: 'load' })`.
Config callback: `onNodesChange`
### edges-change
Fired when edges are added or removed.
```ts
{ type: 'add' | 'remove'; edges: FlowEdge[]; origin: 'drop' | 'paste' | 'api' | 'load' }
```
Same `origin` discriminator as `nodes-change`. `$flow.addEdges` / `$flow.removeEdges` accept `{ source }` to override the default `'api'`.
Config callback: `onEdgesChange`
### nodes-patch
Fired when nodes are patched (partial updates).
```ts
{ patches: Record> }
```
Config callback: `onNodesPatch`
### edges-patch
Fired when edges are patched (partial updates).
```ts
{ patches: Record> }
```
Config callback: `onEdgesPatch`
### node-filter-change
Fired when a node-level filter is applied or cleared.
```ts
{ filtered: FlowNode[]; visible: FlowNode[] }
```
---
## Row Events
### row-select
Fired when a row is selected.
```ts
{ rowId: string; nodeId: string; attrId: string }
```
### row-deselect
Fired when a row is deselected.
```ts
{ rowId: string; nodeId: string; attrId: string }
```
### row-selection-change
Fired whenever the set of selected rows changes.
```ts
{ selectedRows: string[] }
```
---
## Lifecycle Events
### init
Fired after the canvas is fully initialized.
```ts
undefined
```
Config callback: `onInit`
### destroy
Fired when the canvas is being destroyed (cleanup).
```ts
undefined
```
Config callback: `onDestroy`
---
## Additional Events
These events are emitted internally but do not have dedicated config callbacks. Listen via DOM event directives.
| Event | Payload | When |
|---|---|---|
| `save` | `{ nodes, edges, viewport }` | `toObject()` is called |
| `restore` | `{ nodes?, edges?, viewport?, origin }` | `fromObject()` / `$clear()` / `$reset()` / `replaceNodes()` / `undo()` / `redo()` |
| `copy` | `{ nodeCount, edgeCount }` | Clipboard copy |
| `paste` | `{ nodes, edges }` | Clipboard paste |
| `cut` | `{ nodeCount, edgeCount }` | Clipboard cut |
| `layout` | `{ type, positions, ... }` | Layout computed — see below |
| `layout-end` | `{ type, positions, ... }` | The nodes are where the layout put them — see below |
| `compute-complete` | `{ results: Map }` | Compute engine finishes |
| `node-reparent` | `{ node, oldParentId, newParentId }` | Node reparented |
| `child-reorder` | `{ nodeId, parentId, order }` | Child reordered in layout parent |
| `panel-reset` | `undefined` | `resetPanels()` called |
| `helper-lines-change` | `{ horizontal: number[], vertical: number[] }` | Alignment guides update during drag |
`layout` fires when a layout has been **computed**; `layout-end` fires when the nodes have **settled** where it put them. The two are different moments: with the default `duration` the canvas animates towards the new coordinates, so at `layout` time the model still holds the old ones. With `duration: 0` there is nothing to wait for and the pair arrives together — in that order either way.
Both carry `positions` — what the layout decided, `Record` keyed by node id, a plain object so it survives `JSON.stringify` and structured clone. Read it off the event rather than the model, which lags until the motion finishes. Persist from whichever moment suits — `layout` the instant it is decided, `layout-end` once it is on screen:
```html
```
`layout-end` does not fire for a layout that never settled — an animation interrupted by the next one announces nothing. Note that `fitView` runs on the same completion, so the nodes have stopped but the viewport may still be moving; hang off `viewport-move-end` if it is the view you are waiting for.
The rest of each payload is per engine: `{ type: 'dagre', direction }`, `{ type: 'force', charge, distance }`, `{ type: 'tree', layoutType, direction }`, `{ type: 'elk', algorithm, direction }`.
The `restore` event's `origin` field is `'undo' | 'redo' | 'load'` — `'undo'`/`'redo'` from history, and `'load'` from `fromObject()` / `$reset()` / `$clear()` / `replaceNodes()`. Listen via the `flow-restore` DOM event: `@flow-restore="syncSidebar($event.detail)"`. See the [v0.2.1-alpha migration guide](../migration/v0.2.1-alpha.md) for the field's history (it superseded an unreleased `source` tag).
---
## Quick Reference
All events at a glance:
| Event | Payload | Config Callback |
|---|---|---|
| `node-click` | `{ node, event }` | `onNodeClick` |
| `node-drag-start` | `{ node }` | `onNodeDragStart` |
| `node-drag` | `{ node, position }` | `onNodeDrag` |
| `node-drag-end` | `{ node, position }` | `onNodeDragEnd` |
| `node-resize-start` | `{ node, dimensions }` | `onNodeResizeStart` |
| `node-resize` | `{ node, dimensions }` | `onNodeResize` |
| `node-resize-end` | `{ node, dimensions }` | `onNodeResizeEnd` |
| `node-context-menu` | `{ node, event }` | `onNodeContextMenu` |
| `node-collapse` | `{ node, descendants }` | `onNodeCollapse` |
| `node-expand` | `{ node, descendants }` | `onNodeExpand` |
| `node-condense` | `{ node }` | `onNodeCondense` |
| `node-uncondense` | `{ node }` | `onNodeUncondense` |
| `edge-click` | `{ edge, event }` | `onEdgeClick` |
| `edge-context-menu` | `{ edge, event }` | `onEdgeContextMenu` |
| `connect-start` | `{ source, sourceHandle? }` | `onConnectStart` |
| `connect` | `{ connection }` | `onConnect` |
| `multi-connect` | `{ connections }` | `onMultiConnect` |
| `connect-end` | `{ connection?, source, sourceHandle?, position }` | `onConnectEnd` |
| `reconnect-start` | `{ edge, handleType }` | `onReconnectStart` |
| `reconnect` | `{ oldEdge, newConnection }` | `onReconnect` |
| `reconnect-end` | `{ edge, successful }` | `onReconnectEnd` |
| `viewport-change` | `{ viewport }` | `onViewportChange` |
| `viewport-move-start` | `{ viewport }` | `onViewportMoveStart` |
| `viewport-move` | `{ viewport }` | `onViewportMove` |
| `viewport-move-end` | `{ viewport }` | `onViewportMoveEnd` |
| `pane-click` | `{ event, position }` | `onPaneClick` |
| `pane-context-menu` | `{ event, position }` | `onPaneContextMenu` |
| `selection-change` | `{ nodes, edges, rows }` | `onSelectionChange` |
| `selection-context-menu` | `{ nodes, edges, event }` | `onSelectionContextMenu` |
| `nodes-change` | `{ type, nodes, origin }` | `onNodesChange` |
| `edges-change` | `{ type, edges, origin }` | `onEdgesChange` |
| `nodes-patch` | `{ patches }` | `onNodesPatch` |
| `edges-patch` | `{ patches }` | `onEdgesPatch` |
| `node-filter-change` | `{ filtered, visible }` | — |
| `row-select` | `{ rowId, nodeId, attrId }` | — |
| `row-deselect` | `{ rowId, nodeId, attrId }` | — |
| `row-selection-change` | `{ selectedRows }` | — |
| `init` | — | `onInit` |
| `destroy` | — | `onDestroy` |
| `save` | `{ nodes, edges, viewport }` | — |
| `restore` | `{ nodes?, edges?, viewport?, origin }` | — |
| `copy` | `{ nodeCount, edgeCount }` | — |
| `paste` | `{ nodes, edges }` | — |
| `cut` | `{ nodeCount, edgeCount }` | — |
| `layout` | `{ type, positions, ... }` | — |
| `layout-end` | `{ type, positions, ... }` | — |
| `compute-complete` | `{ results: Map }` | — |
| `node-reparent` | `{ node, oldParentId, newParentId }` | — |
| `child-reorder` | `{ nodeId, parentId, order }` | — |
| `panel-reset` | — | — |
| `helper-lines-change` | `{ horizontal, vertical }` | — |
---
## See Also
- [Configuration > Event Callbacks](../configuration/events.md) -- Configuring callbacks
- [$flow Magic](flow-magic/index.md) -- Programmatic API
# TypeScript Types
AlpineFlow exports all key types for use in TypeScript projects.
```ts
import type { FlowNode, FlowEdge, Viewport } from '@getartisanflow/alpineflow';
```
---
## FlowNode
The primary node data structure. Generic parameter `T` defaults to `Record` for the `data` property.
```ts
interface FlowNode> {
/** Unique node identifier. */
id: string;
/** Position in flow coordinates. Relative to parent when `parentId` is set. */
position: XYPosition;
/** Arbitrary data payload for the node. */
data: T;
/** Node type -- maps to a rendering template. Default: 'default' */
type?: string;
/** Width/height, populated after DOM measurement. */
dimensions?: Dimensions;
/**
* Opt-in to inline `style.height` on leaf nodes and opt out of ResizeObserver updates.
* Use for decorative or fixed-size nodes. Container nodes (those with `childLayout`
* or that are parents via `parentId`) receive inline height unconditionally —
* this flag is specifically for making a leaf node behave as fixed-size.
* Auto-set to `true` by the system on resize drag, compute output, and `dimensions.height` animations.
*/
fixedDimensions?: boolean;
/**
* Include this node in the shared ResizeObserver. Default: `true`.
* Set `false` for annotation nodes or decorative overlays where measurement is noise.
*/
resizeObserver?: boolean;
/** Lower bound for observed dimensions. Either axis may be omitted (no constraint on that axis). Applied by the ResizeObserver before updating `node.dimensions`. */
minDimensions?: Partial;
/** Upper bound for observed dimensions. Use `Infinity` for unbounded on one axis. */
maxDimensions?: Partial;
/** Anchor point: [0,0] = top-left (default), [0.5,0.5] = center, [1,1] = bottom-right. */
nodeOrigin?: [number, number];
/** Can this node be dragged? Default: true */
draggable?: boolean;
/** Can edges connect to this node? Default: true */
connectable?: boolean;
/** Handle visibility: 'visible' (default), 'hidden', 'hover', 'select'. */
handles?: 'visible' | 'hidden' | 'hover' | 'select';
/** Can this node be selected? Default: true */
selectable?: boolean;
/** Can this node be resized via x-flow-resizer? Default: true */
resizable?: boolean;
/** Can this node receive keyboard focus? Default: follows nodesFocusable config. */
focusable?: boolean;
/** Override the ARIA role. Default: 'group' */
ariaRole?: string;
/** Override the auto-generated aria-label. */
ariaLabel?: string;
/** Arbitrary DOM attributes (e.g. data-*, aria-describedby). */
domAttributes?: Record;
/** Hide this node from rendering. Connected edges are also hidden. Default: false */
hidden?: boolean;
/** Whether connected nodes are collapsed (hidden). Default: false */
collapsed?: boolean;
/** Whether internal rows are condensed (summary view). Default: false */
condensed?: boolean;
/** Row filter: 'all' | 'connected' | 'unconnected' | ((row) => boolean). */
rowFilter?: RowFilter;
/** Whether excluded by a node-level filter. CSS-driven visibility. */
filtered?: boolean;
/** Dimensions to use when this group node is collapsed. Default: { width: 150, height: 60 } */
collapsedDimensions?: Dimensions;
/** Can this node be deleted via keyboard? Default: true */
deletable?: boolean;
/** Skip reconnection for this node when reconnectOnDelete is enabled. Default: true */
reconnectOnDelete?: boolean;
/** Is this node currently selected? */
selected?: boolean;
/** Optional CSS class(es). */
class?: string;
/** Optional inline styles (string or object). */
style?: string | Record;
/** Parent node ID -- position becomes relative to parent. */
parentId?: string;
/** Clamp child within parent bounds or provide coordinate boundaries. */
extent?: 'parent' | CoordinateExtent;
/** Grow parent dimensions when child reaches edge. Only used when parentId is set. */
expandParent?: boolean;
/** Explicit z-index. For children: computed = parentZ + 1 + zIndex */
zIndex?: number;
/** Default position for source handles. */
sourcePosition?: HandlePosition;
/** Default position for target handles. */
targetPosition?: HandlePosition;
/** Node shape variant (circle, diamond, hexagon, etc.). */
shape?: NodeShape | string;
/** Rotation angle in degrees. Default: 0 */
rotation?: number;
/** When true, this node accepts other nodes dropped onto it as children. */
droppable?: boolean;
/** Predicate to filter which nodes may be dropped into this node. */
acceptsDrop?: (node: FlowNode) => boolean;
/** Opt-in layout for children. Implies preventChildEscape: true. */
childLayout?: ChildLayout;
/** Sort order within a layout parent. Lower = first. */
order?: number;
/** Current child validation errors (internal). */
_validationErrors?: string[];
}
```
---
## FlowEdge
The primary edge data structure.
```ts
interface FlowEdge> {
/** Unique edge identifier. */
id: string;
/** Source node ID. */
source: string;
/** Target node ID. */
target: string;
/** Which handle on the source node. Default: 'source' */
sourceHandle?: string;
/** Which handle on the target node. Default: 'target' */
targetHandle?: string;
/** Edge path type. Default: 'bezier'. */
type?: EdgeType | (string & {});
/** Path style for floating edges. Only used when type is 'floating'. Default: 'bezier' */
pathType?: 'bezier' | 'smoothstep' | 'straight';
/** Animation mode: true/'dash' for scrolling dashes, 'pulse' for breathing, 'dot' for traveling circle. */
animated?: boolean | EdgeAnimationMode;
/** Animation cycle duration (CSS time, e.g. '1s', '300ms'). */
animationDuration?: string;
/** Is this edge currently selected? */
selected?: boolean;
/** Can this edge be reconnected by dragging endpoints? Default: true.
* Set to 'source' or 'target' for one-end-only. */
reconnectable?: boolean | 'source' | 'target';
/** Hide this edge from rendering. Default: false */
hidden?: boolean;
/** Can this edge be deleted via keyboard? Default: true */
deletable?: boolean;
/** Can this edge receive keyboard focus? */
focusable?: boolean;
/** Override ARIA role. Default: 'group' */
ariaRole?: string;
/** Override auto-generated aria-label. */
ariaLabel?: string;
/** Arbitrary DOM attributes. */
domAttributes?: Record;
/** Arbitrary data attached to the edge. */
data?: T;
/** Label visibility: 'always' (default), 'hover', 'selected'. */
labelVisibility?: 'always' | 'hover' | 'selected';
/** Center label text. */
label?: string;
/** Render label, labelStart and labelEnd as HTML rather than text. Default: false */
labelHtml?: boolean;
/** Center label position along path (0 = source, 1 = target). Default: 0.5 */
labelPosition?: number;
/** Label near the source end. */
labelStart?: string;
/** Source label offset in flow coordinates. Default: 30 */
labelStartOffset?: number;
/** Label near the target end. */
labelEnd?: string;
/** Target label offset in flow coordinates. Default: 30 */
labelEndOffset?: number;
/** SVG marker at the start (arrowhead, etc.). */
markerStart?: MarkerType | MarkerConfig;
/** SVG marker at the end (arrowhead, etc.). */
markerEnd?: MarkerType | MarkerConfig;
/** Stroke color -- solid string or gradient object. */
color?: string | EdgeGradient;
/** Gradient direction. Only for EdgeGradient color. Default: 'source-target' */
gradientDirection?: 'source-target' | 'target-source';
/** Visible stroke width in SVG units. Default: 1.5 */
strokeWidth?: number;
/** Invisible hit area width (SVG units). Default: 20 */
interactionWidth?: number;
/** Optional CSS class(es). */
class?: string;
/** Optional inline styles. */
style?: string | Record;
/** Particle/dot fill color. */
particleColor?: string;
/** Particle/dot radius (unitless SVG). */
particleSize?: number;
/** User-placed waypoints for editable edges. */
controlPoints?: { x: number; y: number }[];
/** Path style between control points (editable edges). Default: 'bezier' */
pathStyle?: 'linear' | 'step' | 'smoothstep' | 'catmull-rom' | 'bezier';
/** Always show control point handles (vs only when selected). Default: false */
showControlPoints?: boolean;
}
```
---
## Core Primitives
### Viewport
```ts
interface Viewport {
x: number; // horizontal pan offset
y: number; // vertical pan offset
zoom: number; // zoom level (1 = 100%)
}
```
### XYPosition
```ts
interface XYPosition {
x: number;
y: number;
}
```
### Rect
```ts
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
```
### Dimensions
```ts
interface Dimensions {
width: number;
height: number;
}
```
### CoordinateExtent
```ts
type CoordinateExtent = [[number, number], [number, number]];
// [[minX, minY], [maxX, maxY]]
```
---
## Connection
```ts
interface Connection {
source: string;
sourceHandle?: string;
target: string;
targetHandle?: string;
}
```
---
## Handle Types
### HandlePosition
```ts
type HandlePosition =
| 'top' | 'right' | 'bottom' | 'left'
| 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
```
### HandleType
```ts
type HandleType = 'source' | 'target';
```
---
## Edge Types
### EdgeType
```ts
type EdgeType =
| 'bezier'
| 'smoothstep'
| 'straight'
| 'floating'
| 'orthogonal'
| 'avoidant'
| 'editable';
```
### EdgeAnimationMode
```ts
type EdgeAnimationMode = 'none' | 'dash' | 'pulse' | 'dot';
```
### EdgeGradient
```ts
interface EdgeGradient {
from: string; // start color
to: string; // end color
}
```
---
## Markers
### MarkerType
```ts
type MarkerType = 'arrow' | 'arrowclosed';
```
### MarkerConfig
```ts
interface MarkerConfig {
type: MarkerType;
color?: string;
width?: number;
height?: number;
orient?: string; // Default: 'auto-start-reverse'
offset?: number;
}
```
Markers can be specified as a shorthand string or a full config object:
```js
// Shorthand
{ markerEnd: 'arrowclosed' }
// Full config
{ markerEnd: { type: 'arrow', color: '#ef4444', width: 20, height: 20 } }
```
---
## Animation Types
### AnimateTargets
```ts
interface AnimateTargets {
nodes?: Record;
edges?: Record;
viewport?: AnimateViewportTarget;
}
```
### AnimateNodeTarget
```ts
type AnimateNodeTarget = {
position?: Partial;
data?: Record;
class?: string;
style?: string | Record;
dimensions?: Partial;
selected?: boolean;
zIndex?: number;
_duration?: number; // per-element duration override
}
```
### AnimateEdgeTarget
```ts
type AnimateEdgeTarget = {
color?: string | { from: string; to: string };
label?: string;
strokeWidth?: number;
animated?: boolean;
class?: string;
type?: EdgeType | (string & {}); // path generator; applied instantly, see below
_duration?: number; // per-element duration override
}
```
### AnimateViewportTarget
```ts
type AnimateViewportTarget = {
pan?: Partial;
zoom?: number;
_duration?: number; // per-element duration override
}
```
### AnimateOptions
```ts
interface AnimateOptions {
/** Duration in ms. 0 = instant. Default: 300 */
duration?: number;
/** Easing preset name or custom function. Default: 'easeInOut' */
easing?: EasingName | ((t: number) => number);
/** Delay before starting in ms. Default: 0 */
delay?: number;
/** true = loop forever; 'ping-pong' bounces target↔start forever.
* 'reverse' is a backward-compat alias for 'ping-pong'. Default: false */
loop?: boolean | 'ping-pong' | 'reverse';
/** Called each frame with progress 0-1. */
onProgress?: (progress: number) => void;
/** Called when animation completes. */
onComplete?: () => void;
}
```
### FlowAnimationHandle
```ts
interface FlowAnimationHandle {
pause(): void;
resume(): void;
stop(): void;
reverse(): void;
readonly finished: Promise;
}
```
### FollowOptions
```ts
interface FollowOptions {
zoom?: number;
padding?: number;
easing?: EasingName | ((t: number) => number);
}
```
### ParticleOptions
```ts
interface ParticleOptions {
/** Particle fill color. */
color?: string;
/** Particle radius in SVG user units. */
size?: number;
/** Travel duration (CSS time, e.g. '2s', '300ms'). */
duration?: string;
/** CSS class(es) for the circle element. */
class?: string;
/** Called when the particle reaches the target. */
onComplete?: () => void;
}
```
### ParticleHandle
```ts
interface ParticleHandle {
/** Get the particle's current SVG position, or null if completed. */
getCurrentPosition(): XYPosition | null;
/** Stop and remove the particle immediately. */
stop(): void;
/** Resolves when the particle finishes. */
readonly finished: Promise;
}
```
---
## Child Layout & Validation
### ChildLayout
```ts
interface ChildLayout {
/** Arrangement direction. */
direction: 'vertical' | 'horizontal' | 'grid';
/** How children stretch to fill parent. Default varies by direction. */
stretch?: 'none' | 'width' | 'height' | 'both';
/** Space between children in px. Default: 8 */
gap?: number;
/** Inner padding of parent in px. Default: 12 */
padding?: number;
/** Extra top offset for label/header in px. Default: 0 (auto 30 when label exists) */
headerHeight?: number;
/** Grid-only: number of columns. Default: 2 */
columns?: number;
/** Swap threshold for drag-to-reorder (0-1). Default: 0.5 */
swapThreshold?: number;
}
```
### ChildValidation
```ts
interface ChildValidation {
/** Minimum number of children. */
minChildren?: number;
/** Maximum number of children. */
maxChildren?: number;
/** Shorthand for minChildren: 1. */
requiredChildren?: boolean;
/** Whitelist of allowed child node types. */
allowedChildTypes?: string[];
/** Prevent children from being dragged out. */
preventChildEscape?: boolean;
/** Per-type min/max constraints. */
childTypeConstraints?: Record;
/** Custom validator. Return true or a string error message. */
validateChild?: (child: FlowNode, siblings: FlowNode[]) => boolean | string;
}
```
### ChildValidationResult
```ts
interface ChildValidationResult {
valid: boolean;
rule?: string;
message?: string;
}
```
---
## Keyboard Shortcuts
```ts
interface KeyboardShortcuts {
/** Delete selected elements. Default: ['Delete', 'Backspace'] */
delete?: KeyCode | KeyCode[] | null;
/** Modifier for selection box. Default: 'Shift' */
selectionBox?: KeyCode | null;
/** Modifier for multi-select click. Default: 'Shift' */
multiSelect?: KeyCode | null;
/** Arrow-key node movement. Default: all arrow keys */
moveNodes?: KeyCode | KeyCode[] | null;
/** Base movement step in px. Default: 5 */
moveStep?: number;
/** Modifier that multiplies movement step. Default: 'Shift' */
moveStepModifier?: KeyCode | null;
/** Multiplier when moveStepModifier is held. Default: 4 */
moveStepMultiplier?: number;
/** Copy key (with Ctrl/Cmd). Default: 'c' */
copy?: KeyCode | null;
/** Paste key (with Ctrl/Cmd). Default: 'v' */
paste?: KeyCode | null;
/** Cut key (with Ctrl/Cmd). Default: 'x' */
cut?: KeyCode | null;
/** Undo key (with Ctrl/Cmd). Default: 'z' */
undo?: KeyCode | null;
/** Redo key (with Ctrl/Cmd+Shift). Default: 'z' */
redo?: KeyCode | null;
/** Escape/cancel key. Default: 'Escape' */
escape?: KeyCode | null;
/** Modifier to toggle selection mode during drag. Default: 'Alt' */
selectionModeToggle?: KeyCode | null;
/** Key to toggle box/lasso selection tool. Default: 'l' */
selectionToolToggle?: KeyCode | null;
}
```
Set a shortcut to `null` to disable it. Omit it to use the default.
---
## Node Shapes
### NodeShape
```ts
type NodeShape =
| 'circle'
| 'diamond'
| 'hexagon'
| 'parallelogram'
| 'triangle'
| 'cylinder'
| 'stadium';
```
### ShapeDefinition
```ts
interface ShapeDefinition {
/** CSS clip-path value applied to the node element. */
clipPath?: string;
/** Compute the edge connection point for a handle position on this shape. */
perimeterPoint: (
width: number,
height: number,
position: HandlePosition
) => { x: number; y: number };
}
```
---
## FlowCanvasConfig
The full configuration interface for `flowCanvas()`. Due to its size, it is documented separately in [Configuration](../configuration/index.md). Key categories include:
- Initial state (`nodes`, `edges`, `viewport`)
- Zoom and pan settings (`minZoom`, `maxZoom`, `pannable`, `zoomable`, `panOnScroll`)
- Grid and snapping (`snapToGrid`, `helperLines`)
- Background (`background`, `backgroundGap`, `patternColor`)
- Minimap and controls (`minimap`, `controls`)
- Connection behavior (`connectionMode`, `connectOnClick`, `multiConnect`, `proximityConnect`)
- History (`history`, `historyMaxSize`)
- Type registries (`nodeTypes`, `edgeTypes`, `shapeTypes`)
- Child validation (`childValidationRules`, `onChildValidationFail`)
- Auto-layout (`autoLayout`)
- Event callbacks (`onNodeClick`, `onConnect`, etc.)
- Touch interaction (`longPressAction`, `touchSelectionMode`)
- Accessibility (`nodesFocusable`, `announcements`)
---
## Change Events
### ChangeOrigin
Discriminator on the `nodes-change` / `edges-change` and `restore` events, letting you react only to user intent (e.g. persist a user drop but ignore your own API writes). See [Events](events.md#nodes-change).
```ts
type ChangeOrigin = 'drop' | 'paste' | 'api' | 'load' | 'undo' | 'redo';
```
`nodes-change` / `edges-change` carry the first four (`'drop'` drag-drop, `'paste'` clipboard, `'load'` bulk `fromObject`/`replaceNodes`, `'api'` a direct call — the default). The `restore` event carries `'undo'` / `'redo'` (history) or `'load'` (`fromObject` / `$reset` / `$clear`). The mutators (`addNodes`, `removeNodes`, `addEdges`, `removeEdges`) accept a `{ source?: ChangeOrigin }` option to override the default `'api'`.
---
## Export Types
### ToImageOptions
```ts
interface ToImageOptions {
/** Image width in pixels. Default: 1920 */
width?: number;
/** Image height in pixels. Default: 1080 */
height?: number;
/** Padding as fraction of bounds. Default: 0.1 */
padding?: number;
/** Background color. Default: computed --flow-bg-color */
background?: string;
/** 'all' fits every node; 'viewport' captures current view. Default: 'all' */
scope?: 'all' | 'viewport';
/** Resolution multiplier — `2` renders a 1920x1080 export at 3840x2160.
* No effect on 'svg'. Default: 1 */
scale?: number;
/** Output format. 'svg' returns the vector capture and ignores `scale`. Default: 'png' */
format?: 'png' | 'jpeg' | 'svg';
/** JPEG quality, 0-1. Ignored for other formats. Default: 0.92 */
quality?: number;
/** Triggers a browser download with this filename. */
filename?: string;
/** Include UI overlays. true = all, object = selective. Default: false */
includeOverlays?: boolean | ToImageOverlays;
}
```
### ToImageOverlays
```ts
interface ToImageOverlays {
toolbar?: boolean;
minimap?: boolean;
controls?: boolean;
panels?: boolean;
}
```
---
## Quick Reference
All exported types at a glance:
| Type | Category | Description |
|------|----------|-------------|
| `FlowNode` | Core | Node with position, data, flags, dimensions (`fixedDimensions`, `resizeObserver`, `minDimensions`, `maxDimensions`), shape, rotation |
| `FlowEdge` | Core | Edge with source/target, type, markers, labels, animation, color |
| `Viewport` | Core | `{ x, y, zoom }` — viewport pan/zoom state |
| `XYPosition` | Core | `{ x, y }` — coordinate pair |
| `Rect` | Core | `{ x, y, width, height }` — bounding rectangle |
| `Dimensions` | Core | `{ width, height }` |
| `CoordinateExtent` | Core | `[[minX, minY], [maxX, maxY]]` — boundary constraint |
| `Connection` | Core | `{ source, sourceHandle, target, targetHandle }` |
| `HandlePosition` | Core | `'top' \| 'right' \| 'bottom' \| 'left'` + corners |
| `HandleType` | Core | `'source' \| 'target'` |
| `EdgeType` | Core | `'bezier' \| 'smoothstep' \| 'straight' \| 'orthogonal' \| 'avoidant' \| 'editable' \| 'floating'` |
| `EdgeAnimationMode` | Core | `'dash' \| 'pulse' \| 'dot'` |
| `EdgeGradient` | Core | `{ from: string, to: string }` |
| `MarkerType` | Markers | `'arrow' \| 'arrowclosed'` |
| `MarkerConfig` | Markers | `{ type, color?, width?, height?, orient?, offset? }` |
| `NodeShape` | Shapes | `'diamond' \| 'hexagon' \| 'parallelogram' \| ...` |
| `ShapeDefinition` | Shapes | `{ perimeterPoint, clipPath? }` |
| `AnimateTargets` | Animation | `{ nodes?, edges?, viewport? }` — targets for update/animate |
| `AnimateNodeTarget` | Animation | `{ position?, dimensions?, style?, class?, data?, ... }` |
| `AnimateEdgeTarget` | Animation | `{ color?, strokeWidth?, label?, animated?, ... }` |
| `AnimateViewportTarget` | Animation | `{ pan?, zoom? }` |
| `AnimateOptions` | Animation | `{ duration?, easing?, delay?, loop?, onProgress?, onComplete? }` |
| `FlowAnimationHandle` | Animation | `{ pause, resume, stop, reverse, finished }` |
| `FollowOptions` | Animation | `{ zoom?, padding?, easing? }` |
| `ParticleOptions` | Animation | `{ color?, size?, duration? }` |
| `ParticleHandle` | Animation | `{ getCurrentPosition, stop, finished }` |
| `ChildValidation` | Validation | `{ allowedChildTypes?, minChildren?, maxChildren?, ... }` |
| `ChangeOrigin` | Events | `'drop' \| 'paste' \| 'api' \| 'load' \| 'undo' \| 'redo'` |
| `ChildValidationResult` | Validation | `{ valid, errors }` |
| `ChildLayout` | Layout | `{ direction, gap, padding, ... }` |
| `KeyboardShortcuts` | Input | Customizable key bindings map |
| `FlowCanvasConfig` | Config | Full canvas configuration (see [Configuration](../configuration/index.md)) |
| `ToImageOptions` | Export | `{ width?, height?, padding?, background?, scope?, scale?, format?, quality?, filename? }` |
| `ToImageOverlays` | Export | `{ toolbar?, minimap?, controls?, panels? }` |
| `ResizeDirection` | Resize | `'top' \| 'right' \| 'bottom' \| 'left'` + corners |
| `ResizeConstraints` | Resize | `{ minWidth?, maxWidth?, minHeight?, maxHeight? }` |
---
## See Also
- [Configuration](../configuration/index.md) for `FlowCanvasConfig` details
- [$flow Magic](flow-magic/index.md) for the programmatic API
- [Events](events.md) for `FlowEvents` payload shapes
# $flow Magic
`$flow` is an Alpine.js magic property available inside any `flowCanvas()` scope. It provides programmatic access to the nearest flow canvas instance, returning the full Alpine data object with all reactive state and methods.
```html
```
`$flow` works from any descendant element -- it walks up the DOM to find the nearest `[data-flow-canvas]` container. If used outside a `flowCanvas()` scope, it logs a warning and returns an empty object.
---
## Sections
| Section | Description |
|---------|-------------|
| [State](state.md) | Reactive properties |
| [Nodes](nodes.md) | Node CRUD and queries |
| [Edges](edges.md) | Edge CRUD |
| [Selection](selection.md) | Selection and filtering |
| [Viewport](viewport.md) | Pan, zoom, coordinates |
| [Hierarchy](hierarchy.md) | Parent/child, collapse, condense, transform |
| [Update & Animation](animation.md) | update(), animate(), timeline, particles, follow |
| [Layout](layout.md) | Auto-layout algorithms |
| [State Management](state-management.md) | Serialization, clipboard, history, config |
---
## Quick Reference
All `$flow` methods and properties at a glance:
### Reactive State
| Property | Type | Description |
|----------|------|-------------|
| `nodes` | `FlowNode[]` | All nodes |
| `edges` | `FlowEdge[]` | All edges |
| `viewport` | `Viewport` | Current `{ x, y, zoom }` |
| `selectedNodes` | `Set` | Selected node IDs |
| `selectedEdges` | `Set` | Selected edge IDs |
| `selectedRows` | `Set` | Selected row IDs |
| `ready` | `boolean` | Canvas initialized |
| `isLoading` | `boolean` | Loading state |
| `isInteractive` | `boolean` | Pan/zoom/drag enabled |
| `canUndo` | `boolean` | Undo available |
| `canRedo` | `boolean` | Redo available |
| `colorMode` | `string` | Resolved color mode |
| `contextMenu` | `object` | Context menu state |
| `pendingConnection` | `object?` | Active connection drag |
### Methods
| Method | Signature | Page |
|--------|-----------|------|
| `addNodes` | `(nodes, options?) → void` | [Nodes](nodes.md) |
| `removeNodes` | `(ids) → void` | [Nodes](nodes.md) |
| `batch` | `(fn) → T` | [Nodes](nodes.md) |
| `getNode` | `(id) → FlowNode?` | [Nodes](nodes.md) |
| `getNodeElement` | `(id) → HTMLElement?` | [Nodes](nodes.md) |
| `getNodeIdFromElement` | `(el) → string?` | [Nodes](nodes.md) |
| `getNodeAtPoint` | `(x, y) → FlowNode?` | [Nodes](nodes.md) |
| `getNodesBounds` | `(ids?) → Rect` | [Nodes](nodes.md) |
| `getOutgoers` | `(nodeId) → FlowNode[]` | [Nodes](nodes.md) |
| `getIncomers` | `(nodeId) → FlowNode[]` | [Nodes](nodes.md) |
| `getConnectedEdges` | `(nodeId) → FlowEdge[]` | [Nodes](nodes.md) |
| `areNodesConnected` | `(a, b, directed?) → boolean` | [Nodes](nodes.md) |
| `getIntersectingNodes` | `(node, partially?) → FlowNode[]` | [Nodes](nodes.md) |
| `isNodeIntersecting` | `(node, target, partially?) → boolean` | [Nodes](nodes.md) |
| `setNodeState` | `(id, state) → void` | [Nodes](nodes.md) |
| `resetStates` | `(ids?) → void` | [Nodes](nodes.md) |
| `addEdges` | `(edges) → void` | [Edges](edges.md) |
| `removeEdges` | `(ids) → void` | [Edges](edges.md) |
| `getEdge` | `(id) → FlowEdge?` | [Edges](edges.md) |
| `getEdgePathElement` | `(id) → SVGPathElement?` | [Edges](edges.md) |
| `getEdgeElement` | `(id) → SVGElement?` | [Edges](edges.md) |
| `deselectAll` | `() → void` | [Selection](selection.md) |
| `selectRow` | `(rowId) → void` | [Selection](selection.md) |
| `deselectRow` | `(rowId) → void` | [Selection](selection.md) |
| `toggleRowSelect` | `(rowId) → void` | [Selection](selection.md) |
| `setRowFilter` | `(nodeId, filter) → void` | [Selection](selection.md) |
| `clearNodeFilter` | `() → void` | [Selection](selection.md) |
| `setNodeFilter` | `(predicate) → void` | [Selection](selection.md) |
| `setViewport` | `(viewport, options?) → void` | [Viewport](viewport.md) |
| `zoomIn` | `(options?) → void` | [Viewport](viewport.md) |
| `zoomOut` | `(options?) → void` | [Viewport](viewport.md) |
| `setCenter` | `(x, y, zoom?, options?) → void` | [Viewport](viewport.md) |
| `panBy` | `(dx, dy, options?) → void` | [Viewport](viewport.md) |
| `fitView` | `(options?) → Promise` | [Viewport](viewport.md) |
| `fitBounds` | `(rect, options?) → void` | [Viewport](viewport.md) |
| `getViewportForBounds` | `(bounds, padding?) → Viewport` | [Viewport](viewport.md) |
| `toggleInteractive` | `() → void` | [Viewport](viewport.md) |
| `screenToFlowPosition` | `(x, y) → XYPosition` | [Viewport](viewport.md) |
| `flowToScreenPosition` | `(x, y) → XYPosition` | [Viewport](viewport.md) |
| `getAbsolutePosition` | `(nodeId) → XYPosition` | [Viewport](viewport.md) |
| `layoutChildren` | `(parentId, options?) → void` | [Hierarchy](hierarchy.md) |
| `propagateLayoutUp` | `(parentId, opts?) → void` | [Hierarchy](hierarchy.md) |
| `reorderChild` | `(nodeId, newOrder) → void` | [Hierarchy](hierarchy.md) |
| `reparentNode` | `(nodeId, newParentId) → boolean` | [Hierarchy](hierarchy.md) |
| `collapseNode` | `(id, options?) → void` | [Hierarchy](hierarchy.md) |
| `expandNode` | `(id, options?) → void` | [Hierarchy](hierarchy.md) |
| `toggleNode` | `(id, options?) → void` | [Hierarchy](hierarchy.md) |
| `isCollapsed` | `(id) → boolean` | [Hierarchy](hierarchy.md) |
| `condenseNode` | `(id) → void` | [Hierarchy](hierarchy.md) |
| `uncondenseNode` | `(id) → void` | [Hierarchy](hierarchy.md) |
| `toggleCondense` | `(id) → void` | [Hierarchy](hierarchy.md) |
| `rotateNode` | `(id, angle) → void` | [Hierarchy](hierarchy.md) |
| `registerCompute` | `(nodeType, definition) → void` | [Hierarchy](hierarchy.md) |
| `compute` | `(startNodeId?) → Map` | [Hierarchy](hierarchy.md) |
| `validateParent` | `(nodeId) → { valid, errors }` | [Hierarchy](hierarchy.md) |
| `validateAll` | `() → Map` | [Hierarchy](hierarchy.md) |
| `update` | `(targets, options?) → FlowAnimationHandle` | [Animation](animation.md) |
| `animate` | `(targets, options?) → FlowAnimationHandle` | [Animation](animation.md) |
| `timeline` | `() → FlowTimeline` | [Animation](animation.md) |
| `registerAnimation` | `(name, steps) → void` | [Animation](animation.md) |
| `playAnimation` | `(name) → Promise` | [Animation](animation.md) |
| `follow` | `(target, options?) → FlowAnimationHandle` | [Animation](animation.md) |
| `sendParticle` | `(edgeId, options?) → ParticleHandle?` | [Animation](animation.md) |
| `layout` | `(options?) → void` | [Layout](layout.md) |
| `forceLayout` | `(options?) → void` | [Layout](layout.md) |
| `treeLayout` | `(options?) → void` | [Layout](layout.md) |
| `elkLayout` | `(options?) → Promise` | [Layout](layout.md) |
| `copy` | `() → void` | [State](state-management.md) |
| `paste` | `() → void` | [State](state-management.md) |
| `cut` | `() → Promise` | [State](state-management.md) |
| `undo` | `() → void` | [State](state-management.md) |
| `redo` | `() → void` | [State](state-management.md) |
| `toObject` | `() → { nodes, edges, viewport }` | [State](state-management.md) |
| `fromObject` | `(obj) → void` | [State](state-management.md) |
| `replaceNodes` | `(nodes, edges?) → Promise` | [State](state-management.md) |
| `setNodes` | `(nodes) → Promise` | [State](state-management.md) |
| `$reset` | `() → void` | [State](state-management.md) |
| `$clear` | `() → void` | [State](state-management.md) |
| `toImage` | `(options?) → Promise` | [State](state-management.md) |
| `setLoading` | `(value) → void` | [State](state-management.md) |
| `patchConfig` | `(changes) → void` | [State](state-management.md) |
| `setCrossingReduction` | `(value) → void` | [State](state-management.md) |
| `closeContextMenu` | `() → void` | [State](state-management.md) |
| `resetPanels` | `() → void` | [State](state-management.md) |
| `run` | `(options) → RunHandle` | Workflow addon |
| `resetExecutionLog` | `() → void` | Workflow addon |
# Reactive State
These properties are reactive -- Alpine automatically re-renders when they change.
| Property | Type | Description |
|---|---|---|
| `nodes` | `FlowNode[]` | Reactive array of all nodes on the canvas. |
| `edges` | `FlowEdge[]` | Reactive array of all edges on the canvas. |
| `viewport` | `Viewport` | Current viewport state `{ x, y, zoom }`. |
| `selectedNodes` | `Set` | Set of currently selected node IDs. |
| `selectedEdges` | `Set` | Set of currently selected edge IDs. |
| `selectedRows` | `Set` | Set of selected row IDs (format: `nodeId.attrId`). |
| `ready` | `boolean` | Whether the canvas has completed initialization and first node measurement. |
| `isLoading` | `boolean` | True when the canvas is initializing OR user has set loading. Computed from `ready` and user loading flag. |
| `isInteractive` | `boolean` | Whether pan/zoom/drag interactivity is enabled. |
| `canUndo` | `boolean` | Whether an undo operation is available. Requires `history: true` in config. |
| `canRedo` | `boolean` | Whether a redo operation is available. Requires `history: true` in config. |
| `colorMode` | `'light' \| 'dark' \| undefined` | The current resolved color mode. Requires `colorMode` in config. |
| `contextMenu` | `object` | Context menu state: `{ show, type, x, y, node, edge, position, nodes }`. |
| `pendingConnection` | `object \| null` | Active connection drag: `{ source, sourceHandle?, position }` or null. |
# Node Operations
### addNodes
```ts
$flow.addNodes(
nodes: FlowNode | FlowNode[],
options?: { center?: boolean; source?: ChangeOrigin },
): void
```
Add one or more nodes to the canvas. Accepts a single node or an array.
When `options.center` is set, nodes are placed off-screen for measurement, then repositioned centered on their intended position after dimensions are known.
`options.source` sets the `origin` field on the emitted `nodes-change` event ([ChangeOrigin](../types.md#changeorigin) — `'drop' | 'paste' | 'api' | 'load'`, default `'api'`), so listeners can distinguish a programmatic bulk load from a user action.
Validates child constraints before accepting each node. Captures history, sorts topologically, rebuilds the node map, pushes collab updates, runs child layout, and schedules auto-layout.
For a whole-graph swap (rather than an incremental add), see [`replaceNodes` / `setNodes`](state-management.md#replacenodes--setnodes).
### removeNodes
```ts
$flow.removeNodes(ids: string | string[], options?: { source?: ChangeOrigin }): void
```
Remove one or more nodes by ID. Cascades removal to all descendants (via `parentId` hierarchy). Removes connected edges and optionally creates reconnection bridges (when `reconnectOnDelete` is enabled). Validates child constraints before allowing removal. `options.source` stamps the `nodes-change` origin (default `'api'`).
### batch
```ts
$flow.batch(fn: () => T): T
```
Suspend layout reconciliation for the duration of `fn`, then run a single reconciliation pass after it returns. Ref-counted — nested `batch()` calls join the outer batch rather than triggering early reconciliation. Returns `fn`'s return value. Uses `try/finally` internally so a throwing `fn` still reconciles before the error propagates.
Use `batch()` whenever a single logical operation adds, removes, or reparents multiple nodes that share a parent. Without it, each mutation triggers a layout pass; with it, exactly one pass runs per affected parent.
```js
// Without batch(): 100 addNodes calls → up to 100 layout passes per parent.
// With batch(): one layout pass per parent after all nodes are added.
$flow.batch(() => {
$flow.addNodes(allNodes);
$flow.addEdges(allEdges);
});
```
### getNode
```ts
$flow.getNode(id: string): FlowNode | undefined
```
Look up a node by ID. Returns the reactive node object or `undefined`.
### getNodeElement
```ts
$flow.getNodeElement(id: string): HTMLElement | undefined
```
Returns the rendered DOM element for a node by ID, or `undefined` if the node is not currently in the DOM (hidden, unmounted, or the ID does not exist). Useful for imperative DOM operations such as measuring, scrolling into view, or applying focus.
### getNodeIdFromElement
```ts
$flow.getNodeIdFromElement(el: Element): string | undefined
```
Resolves the node ID from any DOM element that lives inside a flow node. Walks up the DOM to find the nearest `[data-flow-node]` ancestor and returns its `data-flow-node-id` attribute value. Returns `undefined` if `el` is not inside any node element.
### getNodeAtPoint
```ts
$flow.getNodeAtPoint(x: number, y: number): FlowNode | undefined
```
Returns the topmost node whose rendered bounding box contains the given canvas-space coordinates `(x, y)`. When multiple nodes overlap the point, the one with the highest `zIndex` (or last in DOM order on a tie) is returned. Returns `undefined` if no node covers the point.
### getNodesBounds
```ts
$flow.getNodesBounds(nodeIds?: string[]): { x: number; y: number; width: number; height: number }
```
Get the bounding rectangle of the specified nodes. When no IDs are provided, returns bounds for all visible (non-hidden) nodes.
### getOutgoers
```ts
$flow.getOutgoers(nodeId: string): FlowNode[]
```
Get all nodes connected via outgoing edges from the given node.
### getIncomers
```ts
$flow.getIncomers(nodeId: string): FlowNode[]
```
Get all nodes connected via incoming edges to the given node.
### getConnectedEdges
```ts
$flow.getConnectedEdges(nodeId: string): FlowEdge[]
```
Get all edges connected to a node (both incoming and outgoing).
### areNodesConnected
```ts
$flow.areNodesConnected(nodeA: string, nodeB: string, directed?: boolean): boolean
```
Check if two nodes are connected by an edge. When `directed` is true (default: `false`), only checks source-to-target direction.
### getIntersectingNodes
```ts
$flow.getIntersectingNodes(nodeOrId: FlowNode | string, partially?: boolean): FlowNode[]
```
Get nodes whose bounding rectangle overlaps the given node. Accepts either a `FlowNode` object or a node ID string. When `partially` is false, requires full containment.
### isNodeIntersecting
```ts
$flow.isNodeIntersecting(nodeOrId: FlowNode | string, targetOrId: FlowNode | string, partially?: boolean): boolean
```
Check if two nodes' bounding rectangles overlap. Accepts either `FlowNode` objects or node ID strings.
### setNodeState
```ts
$flow.setNodeState(id: string, state: 'pending' | 'running' | 'completed' | 'failed' | 'skipped'): void
```
Set the `runState` property on a node and update its DOM classes. The previous `.flow-node-{state}` class is removed and the new one (`flow-node-pending`, `flow-node-running`, `flow-node-completed`, `flow-node-failed`, `flow-node-skipped`) is added automatically.
The `node.runState` property is reserved by AlpineFlow — avoid setting it directly to ensure DOM classes stay in sync.
### resetStates
```ts
$flow.resetStates(ids?: string[]): void
```
Clear `runState` on all nodes (or only the nodes whose IDs are in `ids`). Removes any `.flow-node-{state}` CSS classes and sets `runState` back to `undefined`.
# Edge Operations
### addEdges
```ts
$flow.addEdges(edges: FlowEdge | FlowEdge[], options?: { source?: ChangeOrigin }): void
```
Add one or more edges to the canvas. Merges `defaultEdgeOptions` from config onto new edges (edge-specific properties override defaults). `options.source` sets the `origin` on the emitted `edges-change` event ([ChangeOrigin](../types.md#changeorigin), default `'api'`).
### removeEdges
```ts
$flow.removeEdges(ids: string | string[], options?: { source?: ChangeOrigin }): void
```
Remove one or more edges by ID. `options.source` stamps the `edges-change` origin (default `'api'`).
### getEdge
```ts
$flow.getEdge(id: string): FlowEdge | undefined
```
Look up an edge by ID. Returns the reactive edge object or `undefined`.
### getEdgePathElement
```ts
$flow.getEdgePathElement(id: string): SVGPathElement | null
```
Get the visible SVG `` element for an edge. The visible path is the second `` child (the first is the invisible interaction hit area).
### getEdgeElement
```ts
$flow.getEdgeElement(id: string): SVGElement | HTMLElement | null
```
Get the container element (SVG group) for an edge.
### getEdgeSvgElement
```ts
$flow.getEdgeSvgElement(): SVGSVGElement | null
```
Get the root SVG element that contains all edges. Useful for whiteboard tools and custom annotation layers.
# Selection
| Method | Signature | Description |
|---|---|---|
| `deselectAll` | `(): void` | Clear all node, edge, and row selections. Removes CSS classes and emits `selection-change`. |
| `selectRow` | `(rowId: string): void` | Select a row by its composite ID (`nodeId.attrId`). Emits `row-select` and `row-selection-change`. |
| `deselectRow` | `(rowId: string): void` | Deselect a row. Emits `row-deselect` and `row-selection-change`. |
| `toggleRowSelect` | `(rowId: string): void` | Toggle a row's selection state. |
| `getSelectedRows` | `(): string[]` | Get an array of all selected row IDs. |
| `isRowSelected` | `(rowId: string): boolean` | Check if a specific row is selected. |
| `deselectAllRows` | `(): void` | Clear all row selections. Removes `.flow-row-selected` CSS class. |
---
# Filtering
### setRowFilter
```ts
$flow.setRowFilter(nodeId: string, filter: RowFilter): void
```
Set a row-level filter on a specific node. `RowFilter` can be `'all'`, `'connected'`, `'unconnected'`, or a custom predicate `(row) => boolean`.
### getRowFilter
```ts
$flow.getRowFilter(nodeId: string): RowFilter
```
Get the current row filter for a node. Returns `'all'` if none is set.
### getVisibleRows
```ts
$flow.getVisibleRows(nodeId: string, schema: any[]): any[]
```
Apply the node's row filter to a schema array and return only the visible rows. When filter is `'connected'`, returns rows whose attribute ID matches an edge handle; `'unconnected'` returns the inverse.
### setNodeFilter
```ts
$flow.setNodeFilter(predicate: (node: FlowNode) => boolean): void
```
Apply a node-level filter. Nodes that fail the predicate get `filtered = true` (CSS-driven visibility). Emits `node-filter-change`.
### clearNodeFilter
```ts
$flow.clearNodeFilter(): void
```
Remove the node filter, restoring all nodes to visible.
# Viewport
### setViewport
```ts
$flow.setViewport(viewport: Partial, options?: { duration?: number }): void
```
Set the viewport programmatically (pan and/or zoom). When `duration` is specified, the transition is animated.
### zoomIn / zoomOut
```ts
$flow.zoomIn(options?: { duration?: number }): void
$flow.zoomOut(options?: { duration?: number }): void
```
Zoom in or out by a step factor, clamped to `minZoom`/`maxZoom`.
### setCenter
```ts
$flow.setCenter(x: number, y: number, zoom?: number, options?: { duration?: number }): void
```
Center the viewport on flow coordinate `(x, y)` at the given zoom level (defaults to current zoom).
### panBy
```ts
$flow.panBy(dx: number, dy: number, options?: { duration?: number }): void
```
Pan the viewport by a delta `(dx, dy)` in pixels.
### fitView
```ts
$flow.fitView(options?: { padding?: number; duration?: number }): Promise
```
Fit all visible nodes into the viewport. Defers via `requestAnimationFrame` if any node lacks measured dimensions (up to 10 retries).
Returns a promise that resolves **`true`** once the fit runs, or **`false`** if the retry budget is exhausted with nodes still unmeasured — so you can observe whether the fit actually happened:
```js
const fitted = await $flow.fitView();
if (!fitted) console.warn('nodes still unmeasured after the retry budget');
```
Runtime-non-breaking: callers that ignore the return value behave exactly as before.
### fitBounds
```ts
$flow.fitBounds(
rect: { x: number; y: number; width: number; height: number },
options?: { padding?: number; duration?: number }
): void
```
Fit a specific rectangle into the viewport. When `duration` is specified, the transition is animated.
### getViewportForBounds
```ts
$flow.getViewportForBounds(
bounds: { x: number; y: number; width: number; height: number },
padding?: number
): Viewport
```
Compute the viewport (pan + zoom) that frames the given bounds within the container, respecting min/max zoom and padding. Does not apply the viewport -- returns it for inspection.
### getContainerDimensions
```ts
$flow.getContainerDimensions(): { width: number; height: number }
```
Get the current pixel width and height of the container element.
### toggleInteractive
```ts
$flow.toggleInteractive(): void
```
Toggle pan/zoom interactivity on and off.
---
# Coordinates
### screenToFlowPosition
```ts
$flow.screenToFlowPosition(x: number, y: number): XYPosition
```
Convert screen coordinates (e.g. from a pointer event) to flow coordinates, accounting for viewport pan and zoom.
### flowToScreenPosition
```ts
$flow.flowToScreenPosition(x: number, y: number): XYPosition
```
Convert flow coordinates to screen coordinates.
### getAbsolutePosition
```ts
$flow.getAbsolutePosition(nodeId: string): XYPosition
```
Get the absolute position of a node, resolving relative positions through the parent hierarchy.
# Hierarchy
### layoutChildren
```ts
$flow.layoutChildren(parentId: string, options?: {
excludeId?: string;
omitFromComputation?: string;
includeNode?: FlowNode;
shallow?: boolean;
stretchedSize?: Dimensions;
}): void
```
Compute and apply child layout for a parent node. Recursively lays out nested layout parents bottom-up (unless `shallow` is true). Applies computed positions, dimension overrides with min/max constraint clamping, and auto-sizes the parent.
As of v0.2.1-alpha, `addNodes` and mutations to `node.childLayout` properties (`columns`, `gap`, `padding`, `headerHeight`, `direction`, `stretch`) trigger `layoutChildren` automatically. Manual calls remain useful when repositioning children by directly mutating `node.position` without going through `addNodes`, or when you need `{ shallow: true }`.
### propagateLayoutUp
```ts
$flow.propagateLayoutUp(startParentId: string, opts?: {
excludeId?: string;
omitFromComputation?: string;
includeNode?: FlowNode;
}): void
```
Walk up from a parent through ancestor layout parents, calling `layoutChildren(shallow)` at each level so parent resizes propagate through the hierarchy.
### reorderChild
```ts
$flow.reorderChild(nodeId: string, newOrder: number): void
```
Reorder a child within its layout parent. Reassigns order values for all siblings, runs `layoutChildren`, and emits a `child-reorder` event.
### reparentNode
```ts
$flow.reparentNode(nodeId: string, newParentId: string | null): boolean
```
Reparent a node into a new parent (or detach from current parent by passing `null`). Handles position conversion between coordinate spaces, validates child constraints on both old and new parents, guards against circular reparenting. Returns `true` on success, `false` if validation rejects.
---
## Collapse / Expand
| Method | Signature | Description |
|---|---|---|
| `collapseNode` | `(id: string, options?: { animate?: boolean; recursive?: boolean }): void` | Collapse a node -- hide its descendants/outgoers. Group nodes shrink to `collapsedDimensions`. Optionally animates (default: true). |
| `expandNode` | `(id: string, options?: { animate?: boolean }): void` | Expand a previously collapsed node -- restore descendants and rerouted edges. |
| `toggleNode` | `(id: string, options?: { animate?: boolean; recursive?: boolean }): void` | Toggle collapse/expand state. |
| `isCollapsed` | `(id: string): boolean` | Check if a node is collapsed. |
| `getCollapseTargetCount` | `(id: string): number` | Get the number of nodes that would be hidden when collapsing this node. |
| `getDescendantCount` | `(id: string): number` | Get the number of descendants (via `parentId` hierarchy) of a node. |
---
## Condense
| Method | Signature | Description |
|---|---|---|
| `condenseNode` | `(id: string): void` | Condense a node -- switch to summary view, hiding internal rows. |
| `uncondenseNode` | `(id: string): void` | Uncondense a node -- restore full row view. |
| `toggleCondense` | `(id: string): void` | Toggle condensed state. |
| `isCondensed` | `(id: string): boolean` | Check if a node is condensed. |
---
## Transform
### rotateNode
```ts
$flow.rotateNode(id: string, angle: number): void
```
Set a node's rotation angle in degrees. The CSS transform is applied by the `x-flow-node` directive.
---
## Compute
### registerCompute
```ts
$flow.registerCompute(nodeType: string, definition: ComputeDefinition): void
```
Register a compute function for a node type. Used by the data propagation engine to compute derived values based on incoming edges.
### compute
```ts
$flow.compute(startNodeId?: string): Map>
```
Run the compute engine, propagating data through nodes in topological order. When `startNodeId` is provided, only recomputes from that node forward. Returns a map of node ID to computed output data. Emits `compute-complete`.
---
## Validation
### validateParent
```ts
$flow.validateParent(nodeId: string): { valid: boolean; errors: string[] }
```
Validate a parent node's child constraints. Returns validation result with any error messages.
### validateAll
```ts
$flow.validateAll(): Map
```
Validate all parent nodes. Returns a map of parent node ID to validation result.
### getValidationErrors
```ts
$flow.getValidationErrors(nodeId: string): string[]
```
Get cached validation errors for a node. Returns an empty array if valid.
# Update & Animation
### update
```ts
$flow.update(targets: AnimateTargets, options?: AnimateOptions): FlowAnimationHandle
```
The core method for applying property changes to nodes, edges, and/or the viewport. Defaults to instant (duration: 0). Pass a duration for smooth transitions. Returns a handle with `pause()`, `resume()`, `stop()`, `reverse()`, and a `finished` promise.
```js
// Instant update (default)
$flow.update({ nodes: { 'node-1': { position: { x: 300, y: 200 } } } });
// Update with smooth transition
$flow.update(
{ nodes: { 'node-1': { position: { x: 300, y: 200 } } } },
{ duration: 500, easing: 'easeInOut' }
);
```
Edge `type` switches the path generator — the same set an edge accepts at build time: `'bezier'`,
`'smoothstep'`, `'straight'`, `'floating'`, `'orthogonal'`, `'avoidant'`, `'editable'`, or a type
registered in `edgeTypes` — and is applied instantly whatever the duration, because a type is a
choice of generator rather than a value with a midpoint:
```js
// Square connections, without rebuilding the graph
$flow.update({ edges: { 'e1': { type: 'smoothstep' } } });
```
### animate
```ts
$flow.animate(targets: AnimateTargets, options?: AnimateOptions): FlowAnimationHandle
```
Convenience wrapper around `update()` that defaults to 300ms smooth transition. Use `update()` for instant changes.
```js
// Smooth transition (300ms default)
$flow.animate({ nodes: { 'node-1': { position: { x: 300, y: 200 } } } });
// Custom timing
$flow.animate(
{ nodes: { 'node-1': { position: { x: 300, y: 200 } } } },
{ duration: 800, easing: 'easeInOut' }
);
```
### timeline
```ts
$flow.timeline(): FlowTimeline
```
Create a new `FlowTimeline` wired to this canvas. Timelines support sequential steps, parallel groups, and lifecycle events (`play`, `pause`, `stop`, `complete`). Lock flag and history suspension are automatically managed.
### registerAnimation / unregisterAnimation
```ts
$flow.registerAnimation(name: string, steps: any[]): void
$flow.unregisterAnimation(name: string): void
```
Register or unregister a named animation (used by the `x-flow-animate` directive).
### playAnimation
```ts
$flow.playAnimation(name: string): Promise
```
Play a named animation registered via `x-flow-animate`. Builds a timeline from the registered steps and plays it.
### group
```ts
$flow.group(name: string): FlowGroup
```
Get or create a named animation group. Groups let you animate multiple nodes as a unit:
```js
const g = $flow.group('sidebar');
g.animate({ position: { x: 300 } }, { duration: 500 });
g.set({ class: 'highlighted' });
```
### transaction
```ts
$flow.transaction(fn: () => void | Promise): Transaction
```
Run a function as an atomic state change. If anything inside throws (or you call `tx.rollback()`), all node/edge positions revert to their snapshot before the function ran:
```js
const tx = await $flow.transaction(async () => {
await $flow.animate({ nodes: { a: { position: { x: 500 } } } }, { duration: 300 }).finished;
await $flow.animate({ nodes: { b: { position: { x: 500 } } } }, { duration: 300 }).finished;
});
// If something went wrong:
tx.rollback();
```
### getHandles
```ts
$flow.getHandles(filter?: { tag?: string; tags?: string[] }): FlowAnimationHandle[]
```
Retrieve all active animation handles, optionally filtered by tag. Useful for inspecting running animations.
### cancelAll / pauseAll / resumeAll
```ts
$flow.cancelAll(filter: { tag?: string; tags?: string[] }, options?: StopOptions): void
$flow.pauseAll(filter: { tag?: string; tags?: string[] }): void
$flow.resumeAll(filter: { tag?: string; tags?: string[] }): void
```
Bulk control for tagged animations. `cancelAll` accepts a `StopOptions` with `mode: 'jump-end' | 'rollback' | 'freeze'`:
```js
// Tag animations when creating them
$flow.animate({ nodes: { a: { position: { x: 300 } } } }, { tag: 'ambient', loop: true });
// Later, control all 'ambient' animations
$flow.pauseAll({ tag: 'ambient' });
$flow.resumeAll({ tag: 'ambient' });
$flow.cancelAll({ tag: 'ambient' }, { mode: 'rollback' });
```
### follow
```ts
$flow.follow(
target: string | FlowAnimationHandle | ParticleHandle | XYPosition,
options?: FollowOptions
): FlowAnimationHandle
```
Track a target with the viewport camera. The target can be a node ID, a `ParticleHandle`, an animation handle, or a static `XYPosition`. The viewport smoothly follows via linear interpolation each frame. Call `.stop()` on the returned handle to stop following.
### sendParticle
```ts
$flow.sendParticle(edgeId: string, options?: ParticleOptions): ParticleHandle | undefined
```
Fire a particle along an edge path. Returns a `ParticleHandle` with `getCurrentPosition()`, `stop()`, and `finished` promise. Options cascade: explicit options > edge properties > CSS variables.
# Layout
### layout (Dagre)
```ts
$flow.layout(options?: {
direction?: 'TB' | 'LR' | 'BT' | 'RL';
nodesep?: number;
ranksep?: number;
adjustHandles?: boolean;
fitView?: boolean;
duration?: number;
}): void
```
Apply Dagre (directed acyclic graph) layout. Requires the dagre addon: `Alpine.plugin(AlpineFlowDagre)`.
All four emit a [`layout` event](../events.md#additional-events) when the layout is computed and
[`layout-end`](../events.md#additional-events) once the nodes have settled there — both carrying
`positions`, keyed by node id. With a `duration` those are different moments (the canvas animates
towards the new coordinates), so read `positions` off the event rather than the model, which lags
one layout behind until the motion finishes.
```html
```
### forceLayout
```ts
$flow.forceLayout(options?: {
strength?: number;
distance?: number;
charge?: number;
iterations?: number;
center?: { x: number; y: number };
fitView?: boolean;
duration?: number;
}): void
```
Apply force-directed layout. Requires the force addon: `Alpine.plugin(AlpineFlowForce)`.
### treeLayout
```ts
$flow.treeLayout(options?: {
layoutType?: 'tree' | 'cluster';
direction?: 'TB' | 'LR' | 'BT' | 'RL';
nodeWidth?: number;
nodeHeight?: number;
adjustHandles?: boolean;
fitView?: boolean;
duration?: number;
}): void
```
Apply hierarchy/tree layout. Requires the hierarchy addon: `Alpine.plugin(AlpineFlowHierarchy)`.
### elkLayout
```ts
$flow.elkLayout(options?: {
algorithm?: 'layered' | 'force' | 'mrtree' | 'radial' | 'stress';
direction?: 'DOWN' | 'RIGHT' | 'UP' | 'LEFT';
nodeSpacing?: number;
layerSpacing?: number;
adjustHandles?: boolean;
fitView?: boolean;
duration?: number;
}): Promise
```
Apply ELK (Eclipse Layout Kernel) layout. Async because ELK's layout returns a Promise. Requires the ELK addon: `Alpine.plugin(AlpineFlowElk)`.
# Clipboard & History
| Method | Signature | Description |
|---|---|---|
| `copy` | `(): void` | Copy selected nodes and their internal edges to the clipboard. |
| `paste` | `(): void` | Paste nodes/edges from the clipboard with new IDs and an accumulating 20px offset. Selects all pasted items. |
| `cut` | `(): Promise` | Copy selected nodes to clipboard, then delete them. |
| `undo` | `(): void` | Undo the last structural change. Requires `history: true`. |
| `redo` | `(): void` | Redo the last undone change. Requires `history: true`. |
---
# State
### toObject
```ts
$flow.toObject(): { nodes: FlowNode[]; edges: FlowEdge[]; viewport: Viewport }
```
Serialize the current canvas state as a deep-cloned plain object. Suitable for saving to a database or local storage. Emits a `save` event.
### fromObject
```ts
$flow.fromObject(obj: {
nodes?: FlowNode[];
edges?: FlowEdge[];
viewport?: Partial;
}): void
```
Restore canvas state from a saved object. Deep-clones incoming data, sorts nodes topologically, rebuilds maps, and applies viewport. Emits a `restore` event.
### $reset
```ts
$flow.$reset(): void
```
Reset the canvas to its initial configuration state (the config passed to `flowCanvas()`).
### $clear
```ts
$flow.$clear(): void
```
Clear all nodes and edges, resetting the viewport to origin `{ x: 0, y: 0, zoom: 1 }`.
### replaceNodes / setNodes
```ts
$flow.replaceNodes(nodes: FlowNode[], edges?: FlowEdge[]): Promise
$flow.setNodes(nodes: FlowNode[]): Promise
```
First-class whole-graph replace, built on the same identity-preserving `fromObject` path (surviving ids keep their live objects; new ids mount fresh and measure). Both emit `restore` with `origin: 'load'` and return a promise that **resolves once the new nodes are measured** — so an immediate `fitView()` fits, with no manual `await nextFrame()`.
- `replaceNodes(nodes, edges?)` swaps the whole graph. `edges` defaults to empty, so `replaceNodes(nodes)` is a genuine whole-graph replace.
- `setNodes(nodes)` replaces just the nodes and keeps the current edges (react-flow-style).
```js
await $flow.replaceNodes(newNodes, newEdges);
await $flow.fitView(); // the new nodes are measured — this fits
```
These are the first-class alternative to the old `$clear()` + `addNodes()` workaround. Server-callable via the `flow:replaceNodes` / `flow:setNodes` wire commands.
### toImage
```ts
$flow.toImage(options?: ToImageOptions): Promise
```
Export the canvas as a data URL image. `html-to-image` ships as a dependency of AlpineFlow, so this works out of the box. Supports custom width, height, padding, background, scope (`'all'` or `'viewport'`), output `format`, overlay inclusion, resolution multiplier via `scale`, and automatic file download via `filename`.
Edges render in the export with their computed stroke, markers, and dash — their stylesheet-driven paint is inlined into the capture so they no longer rasterize invisible.
#### Formats
`format` selects what you get back. The capture is vector either way, so SVG is the intermediate handed straight back rather than extra work.
| Format | Output | Notes |
|---|---|---|
| `'png'` (default) | Rasterized, lossless | Honours `scale`. Best for diagrams — flat colour and text compress well. |
| `'jpeg'` | Rasterized, lossy | Honours `scale` and `quality`. Smaller than PNG, but lossy compression fringes text edges. |
| `'svg'` | Vector | Ignores `scale`. Sharp at any size — but see the file-size warning below. |
> **Warning: SVG files are much larger than you'd expect.** Vector output is *not* the
> small option here. A real 44-node schema graph measured **0.32 MB as PNG and 49.5 MB
> as SVG**.
>
> This is a shortcoming of `html-to-image`, the library that performs the capture — not
> of SVG itself, which is a compact format, nor of anything AlpineFlow controls. Rather
> than emitting the stylesheet once and letting elements share it, html-to-image inlines
> each element's **entire computed style** into its own `style` attribute. Every node
> row, badge and handle ends up carrying a full style declaration, most of it identical
> to its neighbours' and most of it irrelevant. In the capture above, 4,908 `style`
> attributes averaging ~10 KB each accounted for about **98% of the file**. Size
> therefore tracks element count, not visual complexity.
>
> SVG is still the right choice when you need to edit the result as vectors (Figma,
> Illustrator, Inkscape) or scale it arbitrarily. Just don't reach for it to save space,
> and think twice before offering it as a one-click download on large canvases.
```js
// A 2x PNG of the whole graph
await $flow.toImage({ scale: 2, filename: 'graph.png' })
// Vector export — no resolution to pick
await $flow.toImage({ format: 'svg', filename: 'graph.svg' })
```
`quality` (0-1, default `0.92`) applies to JPEG only and is ignored for other formats. Out-of-range values clamp; invalid ones fall back to the default.
#### Scale
`scale` raises the raster resolution without changing the layout — `scale: 2` renders a 1920x1080 export at 3840x2160. The capture is vector, so it re-renders sharp rather than upscaling. It's clamped to what the browser can actually allocate (an over-large canvas would otherwise produce a silently blank image). It has no effect on `format: 'svg'`, which has no raster resolution to multiply.
#### Background
`background` fills behind the capture. It is a *backdrop*, not an override — where the canvas paints its own background (as the default themes do), that wins, and `background` shows through only in transparent regions. This is consistent across all three formats. It matters most for JPEG, which has no alpha channel: without a fill, transparent areas would encode as solid black.
### setLoading
```ts
$flow.setLoading(value: boolean): void
```
Set the user-controlled loading state. When true, `isLoading` becomes true and the loading overlay is shown.
### patchConfig
```ts
$flow.patchConfig(changes: Partial): void
```
Update runtime config options (zoom limits, background, snapping, debug mode, color mode, auto-layout, and more). See [Configuration](../../configuration/index.md) for the full list of patchable options.
### setCrossingReduction
```ts
$flow.setCrossingReduction(value: boolean | { channelGap?: number }): void
```
Toggle avoidant-edge crossing reduction at runtime and re-route immediately — the runtime equivalent of the [`avoidantCrossingReduction`](../../configuration/edges.md#edge-routing) config. Pass `true` to enable with the default lane gap, `{ channelGap: px }` to tune the separation between lanes, or `false` to return to the non-reduced (byte-identical) routing. Server-callable via the `flow:setCrossingReduction` wire command.
### closeContextMenu
```ts
$flow.closeContextMenu(): void
```
Programmatically close the context menu.
### resetPanels
```ts
$flow.resetPanels(): void
```
Reset all panels by dispatching a `flow-panel-reset` event on the container.
---
## See Also
- [Configuration](../../configuration/index.md) -- FlowCanvasConfig options
- [Events](../events.md) -- All events emitted by AlpineFlow
- [Animation](../../animation/animate.md) -- Animation system deep-dive
# CSS Variables
Every visual property in AlpineFlow reads from a `--flow-*` CSS custom property declared on `.flow-container`. The structural layer provides fallback defaults; theme layers override them.
Override any variable on `.flow-container` or inline via `style`:
```css
.flow-container {
--flow-bg-color: #0d1117;
--flow-node-bg: #161b22;
--flow-edge-stroke: #58a6ff;
}
```
```html
```
## System preference
Your host framework (Flux UI, Tailwind, etc.) typically toggles the `.dark` class based on `prefers-color-scheme`. AlpineFlow responds automatically.
## `colorMode` config option
For self-managed color mode without a framework:
```js
flowCanvas({
colorMode: 'system', // 'light' | 'dark' | 'system'
})
```
| Value | Behavior |
|---|---|
| `'light'` | Removes `.dark` from the container |
| `'dark'` | Adds `.dark` to the container |
| `'system'` | Watches `prefers-color-scheme` via `matchMedia`, toggles `.dark` automatically |
| `undefined` | No color mode management (default) -- inherit from ancestor |
The resolved mode is available as a reactive getter: `$flow.colorMode` returns `'light'` or `'dark'`.
## Toggling at runtime
Toggle dark mode programmatically:
```js
// Via colorMode config
$flow.updateConfig({ colorMode: 'dark' });
// Or directly via class
document.querySelector('.flow-container').classList.toggle('dark');
```
## Background patterns
Pattern colors auto-adjust when using the default theme's CSS variables. The theme file sets appropriate values for both light and dark modes, so backgrounds remain visible without manual configuration.
To customize dark mode colors explicitly, override the CSS variables within a dark mode selector in your stylesheet.
# Custom Themes
AlpineFlow separates layout from appearance, letting you build a fully custom theme on top of the structural CSS layer.
## CSS architecture
AlpineFlow ships three CSS layers:
| File | Purpose | Required? |
|---|---|---|
| `structural.css` | Positioning, z-index, cursors, transforms, `var()` references | Yes |
| `theme-default.css` | Neutral zinc/slate theme with light + dark mode | Optional |
| `theme-flux.css` | Tailwind v4 / Flux UI native theme using design tokens | Optional |
The barrel file `alpineflow.css` imports structural + default:
```css
/* alpineflow.css */
@import './alpineflow/structural.css';
@import './alpineflow/theme-default.css';
```
## Structural-only mode
Import just `structural.css` for positioning and interaction with zero visual opinions:
```css
@import './alpineflow/structural.css';
/* Bring your own theme below */
```
All positioning, z-indexing, cursors, and transforms work. Every visual property reads from `var(--flow-*)` with minimal fallbacks (transparent backgrounds, no shadows, no borders). Set the variables you care about and leave the rest.
This is useful when embedding AlpineFlow into a design system with its own tokens.
::demo
```html
```
::enddemo
## Accent stripe
The default theme adds a distinctive colored top border to nodes via `--flow-node-border-top`:
```css
/* Light mode: subtle zinc stripe */
--flow-node-border-top: 2.5px solid #d4d4d8;
/* Dark mode: slightly brighter */
--flow-node-border-top: 2.5px solid #52525b;
```
The structural CSS applies this with a fallback:
```css
.flow-node {
border-top: var(--flow-node-border-top, var(--flow-node-border));
}
```
To disable the accent stripe and use the same border on all sides:
```css
.flow-container {
--flow-node-border-top: var(--flow-node-border);
}
```
To use a brand-colored stripe:
```css
.flow-container {
--flow-node-border-top: 2.5px solid #3b82f6;
}
```
## Flux theme
The Flux theme maps all `--flow-*` variables to Tailwind v4 design tokens and Flux UI's `--color-accent` system. Flowcharts automatically match your app's look and adapt to any TweakFlux preset (Dracula, Nord, Catppuccin, etc.).
```css
@import './alpineflow/structural.css';
@import './alpineflow/theme-flux.css';
```
Key mappings:
| AlpineFlow Variable | Flux Token |
|---|---|
| `--flow-node-hover-border-color` | `var(--color-accent)` |
| `--flow-node-selected-border-color` | `var(--color-accent)` |
| `--flow-edge-stroke-selected` | `var(--color-accent)` |
| `--flow-node-bg` | `var(--color-white)` / `var(--color-zinc-800)` |
| `--flow-node-border` | `var(--color-zinc-200)` / `var(--color-zinc-700)` |
| `--flow-node-border-radius` | `var(--radius-lg)` |
| `--flow-node-shadow` | `var(--shadow-xs)` |
| `--flow-selection-bg` | `color-mix(in oklab, var(--color-accent) 6%, transparent)` |
The Flux theme uses `color-mix(in oklab, ...)` for accent transparencies, matching Flux UI's convention.
## Building a custom theme file
Create a new CSS file that sets `--flow-*` variables on `.flow-container`:
```css
/* my-theme.css */
.flow-container {
--flow-bg-color: #1e1e2e;
--flow-node-bg: #313244;
--flow-node-color: #cdd6f4;
--flow-node-border: 1px solid #45475a;
--flow-node-border-radius: 8px;
--flow-node-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
--flow-edge-stroke: #6c7086;
--flow-handle-bg: #89b4fa;
/* ... set as many or as few variables as needed */
}
```
Import it after the structural layer:
```css
@import './alpineflow/structural.css';
@import './my-theme.css';
```
Any variables you don't set will use the structural layer's minimal fallbacks.
# Whiteboard
The Whiteboard addon adds freehand drawing, highlighting, shape drawing, text placement, and erasing capabilities to your flow canvas.
::demo
```html
```
::enddemo
## Installation
```js
import AlpineFlowWhiteboard from '@getartisanflow/alpineflow/whiteboard'
Alpine.plugin(AlpineFlowWhiteboard)
```
No additional peer dependencies are required.
## With WireFlow
If you're using [WireFlow](https://artisanflow.dev/docs/wireflow) (AlpineFlow's Livewire integration), the core is loaded from the WireFlow vendor bundle. Addons work seamlessly — they share a global registry with the core, regardless of how each was loaded.
> Install `@getartisanflow/alpineflow` via npm to access addon sub-path imports.
```js
// Core from WireFlow vendor bundle
import AlpineFlow from '../../vendor/getartisanflow/wireflow/dist/alpineflow.bundle.esm.js';
// Addon from npm
import AlpineFlowWhiteboard from '@getartisanflow/alpineflow/whiteboard';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
window.Alpine.plugin(AlpineFlowWhiteboard);
});
```
## Directives
All whiteboard directives are placed on the `.flow-container` element. The expression for each directive is a boolean that controls whether the tool is currently active.
| Directive | Description |
|---|---|
| `x-flow-freehand` | Freehand pen drawing with pressure-sensitive strokes |
| `x-flow-highlighter` | Semi-transparent highlighter strokes |
| `x-flow-arrow-draw` | Click-and-drag to draw arrow annotations |
| `x-flow-circle-draw` | Click-and-drag to draw circle annotations |
| `x-flow-rectangle-draw` | Click-and-drag to draw rectangle annotations |
| `x-flow-text-tool` | Click to place editable text annotations |
| `x-flow-eraser` | Drag to paint over elements, release to delete |
### Basic usage
```html
```
## Tool Settings
Configure drawing properties via `toolSettings` — an object with `strokeColor`, `strokeWidth`, and `opacity`. The directives read this from the Alpine scope via `Alpine.$data(el).toolSettings`.
### With raw Alpine (recommended pattern)
Spread `flowCanvas()` into a parent scope alongside `tool` and `toolSettings`:
```html
...
```
> **Important:** Toolbar elements inside the `.flow-container` must use the `canvas-overlay` class with `@mousedown.stop @pointerdown.stop`. Drawing directives use capture-phase pointer handlers that intercept all events inside the container — without `canvas-overlay`, tool buttons won't respond to clicks while a drawing tool is active. On touch devices, ensure toolbar buttons are large enough to tap comfortably (44×44px minimum is recommended) — see [Touch & Mobile](../interaction/touch.md).
### With WireFlow ``
Since `` creates its own `x-data="flowCanvas({...})"`, you can't define `tool` and `toolSettings` in a parent scope — directives on the `` element evaluate in the flowCanvas scope, not the parent. Use `x-init` with `Object.assign($data, ...)` to inject properties into the flowCanvas scope:
```blade
```
> **Important:** Do NOT pass `toolSettings` inside the `config` prop. It must be a top-level Alpine scope property, not a config option.
### Listening for drawing events in WireFlow
Drawing tool events (`flow-freehand-end`, `flow-rectangle-draw`, etc.) are dispatched on the `.flow-container` element. In WireFlow, you can't use `@@event` attributes on `` (Livewire 4 crashes on custom event names with hyphens). Instead, attach listeners in `x-init`:
```blade
```
## Complete Example
A working whiteboard with freehand, highlighter, arrow, rectangle, circle, text, and eraser tools. This shows the full pattern including event listeners and annotation node templates.
### Event listeners
Each tool emits an event when a drawing action completes. You must handle these events to create annotation nodes:
```html
@flow-freehand-end="addNodes([{
id: 'ann-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
position: { x: 0, y: 0 },
draggable: false, selectable: false,
class: 'flow-node-annotation',
data: { annotation: 'drawing', pathData: $event.detail.pathData, strokeColor: $event.detail.strokeColor, opacity: $event.detail.opacity },
}])"
@flow-highlight-end="addNodes([{
id: 'ann-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
position: { x: 0, y: 0 },
draggable: false, selectable: false,
class: 'flow-node-annotation',
data: { annotation: 'highlight', pathData: $event.detail.pathData, strokeColor: $event.detail.strokeColor, opacity: $event.detail.opacity },
}])"
@flow-rectangle-draw="addNodes([{
id: 'ann-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
position: { x: $event.detail.bounds.x, y: $event.detail.bounds.y },
draggable: false, selectable: false,
class: 'flow-node-annotation',
data: { annotation: 'rectangle', w: $event.detail.bounds.width, h: $event.detail.bounds.height, strokeColor: $event.detail.strokeColor, strokeWidth: $event.detail.strokeWidth, opacity: $event.detail.opacity },
}])"
@flow-arrow-draw="addNodes([{
id: 'ann-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
position: { x: 0, y: 0 },
draggable: false, selectable: false,
class: 'flow-node-annotation',
data: { annotation: 'arrow', start: $event.detail.start, end: $event.detail.end, strokeColor: $event.detail.strokeColor, strokeWidth: $event.detail.strokeWidth, opacity: $event.detail.opacity },
}])"
@flow-circle-draw="addNodes([{
id: 'ann-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
position: { x: 0, y: 0 },
draggable: false, selectable: false,
class: 'flow-node-annotation',
data: { annotation: 'circle', cx: $event.detail.cx, cy: $event.detail.cy, rx: $event.detail.rx, ry: $event.detail.ry, strokeColor: $event.detail.strokeColor, strokeWidth: $event.detail.strokeWidth, opacity: $event.detail.opacity },
}])"
@flow-text-draw="addNodes([{
id: 'ann-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7),
position: { x: $event.detail.position.x, y: $event.detail.position.y },
draggable: false, selectable: false,
class: 'flow-node-annotation',
data: { annotation: 'text', text: '', strokeColor: $event.detail.strokeColor, fontSize: $event.detail.fontSize, opacity: $event.detail.opacity },
}])"
```
These event attributes go on the same element as the `x-flow-freehand` directives (the `.flow-container`).
> **WireFlow note:** In WireFlow's ``, use `$el.addEventListener()` in `x-init` instead of `@event` attributes. See [Listening for drawing events in WireFlow](#listening-for-drawing-events-in-wireflow) above.
### Annotation node templates
Annotations are stored as nodes with `class: 'flow-node-annotation'` and a `data.annotation` field that identifies the type. Your `x-for` node template must render each type:
```html
```
Key points:
- Annotation SVGs use `position:absolute;width:1px;height:1px;overflow:visible` to render at flow coordinates without affecting node sizing
- `draggable: false` and `selectable: false` prevent annotations from being interacted with as nodes
- `.flow-node-annotation` CSS class strips default node styling (background, border, shadow)
- The eraser tool doesn't need an event listener — it deletes nodes directly
## Annotations as Nodes
All annotations are stored as regular nodes via `addNodes()`. This means they automatically integrate with:
- **Undo/redo** — annotation creation and deletion are part of the history stack.
- **Collaboration** — annotations sync across users via Yjs shared types.
The `.flow-node-annotation` CSS class is applied to annotation nodes, which strips the default node styling (background, border, shadow) so the drawn content renders cleanly.
## Events
Each tool emits a custom event when a drawing action completes:
| Event | Emitted by |
|---|---|
| `flow-freehand-end` | `x-flow-freehand` |
| `flow-highlight-end` | `x-flow-highlighter` |
| `flow-arrow-draw` | `x-flow-arrow-draw` |
| `flow-circle-draw` | `x-flow-circle-draw` |
| `flow-rectangle-draw` | `x-flow-rectangle-draw` |
| `flow-text-draw` | `x-flow-text-tool` |
## Eraser
The eraser tool uses a drag-to-paint interaction model. Drag over elements to mark them for deletion, then release to remove them. Internally, it uses segment-rect intersection to determine which elements fall under the eraser path.
## See Also
- [Installation > Addons](../getting-started/installation.md#optional-addons)
# Collaboration
The Collaboration addon enables real-time multi-user editing of flow diagrams using [Yjs](https://yjs.dev/) conflict-free replicated data types (CRDTs).
> **Live demo:** See the [Live Collaboration example](/examples/collaboration) — two side-by-side canvases syncing nodes, edges, cursors, and whiteboard annotations in real time.
## Installation
Install the required peer dependencies:
```bash
npm install yjs y-websocket y-protocols
```
Then register the plugin:
```js
import AlpineFlowCollab from '@getartisanflow/alpineflow/collab'
Alpine.plugin(AlpineFlowCollab)
```
## With WireFlow
If you're using [WireFlow](https://artisanflow.dev/docs/wireflow) (AlpineFlow's Livewire integration), the core is loaded from the WireFlow vendor bundle. Addons work seamlessly — they share a global registry with the core, regardless of how each was loaded.
The `yjs`, `y-websocket`, and `y-protocols` peer dependencies are still required.
> Install `@getartisanflow/alpineflow` via npm to access addon sub-path imports.
```js
// Core from WireFlow vendor bundle
import AlpineFlow from '../../vendor/getartisanflow/wireflow/dist/alpineflow.bundle.esm.js';
// Addon from npm
import AlpineFlowCollab from '@getartisanflow/alpineflow/collab';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
window.Alpine.plugin(AlpineFlowCollab);
});
```
## Configuration
Pass a `collab` object in your flow canvas configuration with a provider instance:
```js
import { WebSocketProvider } from '@getartisanflow/alpineflow/collab'
const provider = new WebSocketProvider({
roomId: 'my-room',
url: 'ws://localhost:1234',
user: { name: 'Alice', color: '#ef4444' },
});
```
```html
```
## Providers
Three provider types are available:
| Provider | Description | Requires |
|---|---|---|
| `WebSocketProvider` | Standard y-websocket connection | A y-websocket server |
| `ReverbProvider` | Laravel Reverb/Pusher integration | Laravel Echo + Reverb |
| `InMemoryProvider` | Local testing, no server | Nothing |
### WebSocket provider
Uses [y-websocket](https://github.com/yjs/y-websocket) for binary WebSocket transport — the most efficient option. Requires a y-websocket server.
```js
import { WebSocketProvider } from '@getartisanflow/alpineflow/collab'
const provider = new WebSocketProvider({
roomId: 'diagram-1',
url: 'ws://localhost:1234',
user: { name: 'Alice', color: '#3b82f6' },
});
```
```js
collab: {
provider,
user: { name: 'Alice', color: '#3b82f6' },
}
```
### Laravel Reverb provider
Uses Laravel Echo private channels with whisper events. Encodes Yjs updates as base64 text since Reverb/Pusher only supports text frames.
The provider handles **peer-to-peer state sync** automatically — when a new user connects, it requests the current document state from existing peers via whisper, ensuring all clients start from the same Yjs state.
```js
import { ReverbProvider } from '@getartisanflow/alpineflow/collab'
const provider = new ReverbProvider({
roomId: 'diagram-1',
channel: 'flow.{roomId}',
user: { name: 'Alice', color: '#3b82f6' },
});
```
```js
collab: {
provider,
user: { name: 'Alice', color: '#3b82f6' },
}
```
> **Important:** Laravel Echo must be initialized before the flow canvas mounts. The provider accesses `window.Echo` to join the private channel.
#### Reverb channel authorization
The channel name follows the pattern you specify (e.g., `flow.{roomId}`). Register the authorization route in your Laravel `channels.php`:
```php
Broadcast::channel('flow.{roomId}', function ($user, $roomId) {
return ['id' => $user->id, 'name' => $user->name];
});
```
#### Optional: server-side state persistence
Pass a `stateUrl` to load the initial document state from your server instead of peer sync:
```js
const provider = new ReverbProvider({
roomId: 'diagram-1',
channel: 'flow.{roomId}',
user: { name: 'Alice', color: '#3b82f6' },
stateUrl: '/api/flow/{roomId}/state',
});
```
The provider fetches `GET /api/flow/diagram-1/state` and expects `{ state: "" }`. This is useful for persisting diagram state between sessions.
### InMemoryProvider
For testing and demos, `InMemoryProvider` requires no server. Use `linkProviders()` to synchronize two in-memory providers:
```js
import { InMemoryProvider, linkProviders } from '@getartisanflow/alpineflow/collab'
const providerA = new InMemoryProvider({ roomId: 'my-room' })
const providerB = new InMemoryProvider({ roomId: 'my-room' })
linkProviders(providerA, providerB)
```
> **Inline scripts:** `InMemoryProvider` and `linkProviders` are named exports from the collab module. If you need to access them from a `
```
::enddemo
## Related
- [Update & Animate](basics.md) -- server-side flowUpdate/flowAnimate
- [Particles](particles.md) -- fire particles along edges
- [Camera Control](camera.md) -- focus and follow nodes
# Path Motion
Move nodes along arbitrary curves instead of straight-line interpolation. AlpineFlow provides built-in path functions for orbits, waves, pendulums, and more, plus raw SVG path support.
::demo
```toolbar
```
```html
```
::enddemo
## Server-Side: SVG Path Strings
You can pass SVG path `d` strings to `flowAnimate()` from your Livewire component. The node will follow the curve instead of moving in a straight line:
```php
// Move a node along a quadratic bezier curve
$this->flowAnimate([
'nodes' => [
'data-packet' => [
'followPath' => 'M 0 100 Q 200 0 400 100',
],
],
], ['duration' => 2000]);
```
This works because `followPath` accepts either a JavaScript path function or an SVG path string. The string is converted to a path function on the client side automatically.
> **Note:** Only SVG path strings work from the server. JavaScript path functions like `orbit()` and `wave()` must be called from the client side (see below).
## Client-Side: Path Functions
For programmatic paths, use `$flow.animate()` directly in your Blade template. AlpineFlow provides these built-in path functions:
### orbit()
Circular or elliptical motion around a center point:
```blade
...
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `cx` | `number` | (required) | Center X |
| `cy` | `number` | (required) | Center Y |
| `radius` | `number` | `100` | Radius (or use `rx`/`ry` for ellipse) |
| `rx` | `number` | — | Horizontal radius (overrides `radius`) |
| `ry` | `number` | — | Vertical radius (overrides `radius`) |
| `offset` | `number` | `0` | Start angle (0-1, where 1 = full rotation) |
| `clockwise` | `boolean` | `true` | Direction |
### wave()
Sinusoidal oscillation along a start-end axis:
```js
$flow.animate({
nodes: {
'signal': {
followPath: wave({
startX: 0, startY: 100,
endX: 400, endY: 100,
amplitude: 50,
frequency: 2,
}),
},
},
}, { duration: 2000, loop: 'reverse' });
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `startX` | `number` | (required) | Start X |
| `startY` | `number` | (required) | Start Y |
| `endX` | `number` | (required) | End X |
| `endY` | `number` | (required) | End Y |
| `amplitude` | `number` | `30` | Wave height |
| `frequency` | `number` | `1` | Number of full cycles |
| `offset` | `number` | `0` | Phase offset (0-1) |
### along()
Follow an SVG path with optional reverse and subsection control:
```js
// Follow entire path
$flow.animate({
nodes: { 'n1': { followPath: along('M 0 0 C 100 0 200 100 300 50') } },
}, { duration: 2000 });
// Reverse, middle 50% only
along('M 0 0 C 100 0 200 100 300 50', {
reverse: true,
startAt: 0.25,
endAt: 0.75,
})
```
### pendulum()
Swinging arc around a pivot point:
```js
pendulum({
cx: 200, // Pivot X
cy: 50, // Pivot Y
radius: 100, // Arm length
angle: 60, // Max swing degrees (default: 60)
offset: 0, // Phase offset (0-1)
})
```
### drift()
Smooth pseudo-random wandering for ambient effects:
```js
drift({
originX: 200, // Center X
originY: 200, // Center Y
range: 20, // Max displacement (default: 20)
speed: 1, // Speed multiplier (default: 1)
seed: 0, // Vary pattern per node (default: 0)
})
```
### stagger()
Distributes offset values across multiple items — useful for spacing orbit or wave phases:
```js
const offsets = stagger(0.25, { from: 0 });
['s1', 's2', 's3', 's4'].forEach((id, i, arr) => {
$flow.animate({
nodes: { [id]: { followPath: orbit({ cx: 200, cy: 200, radius: 100, offset: offsets(i, arr.length) }) } },
}, { duration: 4000, loop: true, easing: 'linear' });
});
```
## Using with Timeline
Path functions work in timeline steps:
```blade
...
```
## Guide Paths
When using an SVG path string, you can show a visible guide overlay:
```js
$flow.animate({
nodes: {
'n1': {
followPath: 'M 0 100 Q 200 0 400 100',
guidePath: {
visible: true,
class: 'my-guide',
autoRemove: true,
},
},
},
}, { duration: 2000 });
```
Guide paths get the `.flow-guide-path` CSS class:
```css
.flow-guide-path {
stroke: var(--flow-accent);
stroke-width: 1;
stroke-dasharray: 4 4;
fill: none;
opacity: 0.4;
}
```
## Custom Path Functions
Any `(t: number) => { x, y }` function works as a path:
```js
// Figure-eight
const figure8 = (t) => ({
x: 200 + 100 * Math.sin(t * Math.PI * 2),
y: 100 + 50 * Math.sin(t * Math.PI * 4),
});
$flow.animate({
nodes: { 'n1': { followPath: figure8 } },
}, { duration: 4000, loop: true, easing: 'linear' });
```
## Related
- [Update & Animate](basics.md) -- flowUpdate and flowAnimate
- [Timeline](timeline.md) -- multi-step animations via Alpine
- [Camera Control](camera.md) -- focus and follow nodes
# Particles
Particles are small animated dots that travel along edges, showing data flow, progress, or activity. Fire them from the server with `flowSendParticle()` on individual edges, or use `flowHighlightPath()` to cascade particles through a sequence of nodes.
## flowSendParticle
Fire a single particle along an edge:
```php
$this->flowSendParticle('e-api-db');
```
### Options
Customize the particle appearance and speed:
```php
$this->flowSendParticle('e-api-db', [
'color' => '#3b82f6', // Particle color (default: theme accent)
'size' => 6, // Particle radius in pixels (default: 4)
'duration' => 1500, // Travel time in ms (default: 1000)
]);
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `color` | `string` | Theme accent | CSS color value |
| `size` | `int` | `4` | Particle radius in pixels |
| `duration` | `int` | `1000` | Travel time in milliseconds |
### Firing multiple particles
Call `flowSendParticle()` multiple times to fire particles on different edges:
```php
public function showDataFlow(): void
{
$this->flowSendParticle('e-api-auth', ['color' => '#3b82f6']);
$this->flowSendParticle('e-api-db', ['color' => '#22c55e']);
$this->flowSendParticle('e-db-cache', ['color' => '#f59e0b']);
}
```
## flowHighlightPath
Fire cascading particles along edges that connect a sequence of nodes. Particles fire segment by segment with a configurable delay between each:
```php
$this->flowHighlightPath(['step-1', 'step-2', 'step-3', 'step-4']);
```
The method finds the edges connecting each consecutive pair of nodes and fires particles along each one in sequence.
### Options
```php
$this->flowHighlightPath(['start', 'process', 'review', 'done'], [
'color' => '#22c55e', // Particle color
'size' => 5, // Particle radius
'duration' => 800, // Travel time per segment
'delay' => 200, // Delay between each segment
]);
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `color` | `string` | Theme accent | CSS color value |
| `size` | `int` | `4` | Particle radius in pixels |
| `duration` | `int` | `1000` | Travel time per edge segment |
| `delay` | `int` | `100` | Delay before starting the next segment |
## CSS styling
Particles render as `.flow-particle` elements inside the canvas SVG layer. Override the defaults with CSS:
```css
.flow-particle {
filter: blur(1px); /* Soft glow effect */
mix-blend-mode: screen; /* Blend with dark backgrounds */
}
```
You can also target particles by color or add animations:
```css
.flow-particle {
filter: drop-shadow(0 0 4px currentColor);
}
```
## Complete example
A pipeline monitor that fires particles to show data flowing through the system:
```php
'ingest', 'position' => ['x' => 0, 'y' => 0], 'data' => ['label' => 'Ingest']],
['id' => 'transform', 'position' => ['x' => 250, 'y' => 0], 'data' => ['label' => 'Transform']],
['id' => 'validate', 'position' => ['x' => 500, 'y' => 0], 'data' => ['label' => 'Validate']],
['id' => 'store', 'position' => ['x' => 750, 'y' => 0], 'data' => ['label' => 'Store']],
];
public array $edges = [
['id' => 'e-1', 'source' => 'ingest', 'target' => 'transform'],
['id' => 'e-2', 'source' => 'transform', 'target' => 'validate'],
['id' => 'e-3', 'source' => 'validate', 'target' => 'store'],
];
#[Renderless]
public function fireSingle(): void
{
$this->flowSendParticle('e-1', [
'color' => '#3b82f6',
'size' => 5,
'duration' => 1200,
]);
}
#[Renderless]
public function fireAll(): void
{
$this->flowSendParticle('e-1', ['color' => '#3b82f6']);
$this->flowSendParticle('e-2', ['color' => '#8b5cf6']);
$this->flowSendParticle('e-3', ['color' => '#22c55e']);
}
#[Renderless]
public function firePath(): void
{
$this->flowHighlightPath(['ingest', 'transform', 'validate', 'store'], [
'color' => '#f59e0b',
'size' => 6,
'duration' => 800,
'delay' => 150,
]);
}
public function render()
{
return view('livewire.particle-demo');
}
}
```
```blade
{{-- resources/views/livewire/particle-demo.blade.php --}}
```
## v0.2.0-alpha additions
### More firing methods
Four additional server-side particle methods cover cases beyond "fire one particle on an existing edge":
| Method | What it does |
|---|---|
| `flowSendParticleAlongPath(string $path, array $options)` | Fire along an arbitrary SVG path string — no edge required |
| `flowSendParticleBetween(string $sourceId, string $targetId, array $options)` | Fire along a straight line between two node centers |
| `flowSendParticleBurst(string $edgeId, array $options)` | Fire N particles on one edge, staggered in time (`count`, `stagger`) |
| `flowSendConverging(array $edgeIds, array $options)` | Fire particles from multiple edges that converge at a target node simultaneously |
```php
// Arbitrary path — good for ad-hoc visualizations (e.g. a "transfer" effect
// between canvases or elements that aren't connected by an edge)
$this->flowSendParticleAlongPath('M 60 180 Q 220 40 380 180', [
'color' => '#8b5cf6',
'duration' => 1600,
]);
// Burst of 6 orbs, 120ms apart
$this->flowSendParticleBurst('e-main', [
'count' => 6,
'stagger' => 120,
'renderer' => 'orb',
'size' => 5,
'duration' => 1000,
]);
// Converging fan-in — 3 sources arrive at a sink node simultaneously
$this->flowSendConverging(['e-src-1', 'e-src-2', 'e-src-3'], [
'targetNodeId' => 'sink',
'synchronize' => 'arrival',
'duration' => 1400,
]);
```
### Renderers — including the beam
`$options['renderer']` picks which visual renderer draws the particle. Built-ins: `'circle'` *(default)*, `'orb'`, `'beam'`, `'pulse'`, `'image'`.
The **beam** renderer is the photogenic one — it renders as a traveling segment of the path itself (follows curvature correctly on bezier edges) and supports multi-stop gradients painted tail→head:
```php
$this->flowSendParticle('e-1', [
'renderer' => 'beam',
'length' => 60, // beam length in SVG user units
'width' => 4, // stroke thickness
'gradient' => [
['offset' => 0, 'color' => '#8B5CF6', 'opacity' => 0], // transparent tail
['offset' => 0.7, 'color' => '#D946EF', 'opacity' => 0.9], // magenta mid
['offset' => 1, 'color' => '#fff', 'opacity' => 1], // bright head
],
]);
```
By default the beam's tail catches up to the target after the head arrives (follow-through). If you need `onComplete`-equivalent timing at head-arrival, pass `'followThrough' => false`:
```php
$this->flowSendParticle('e-1', [
'renderer' => 'beam',
'followThrough' => false, // beam vanishes when head hits target
'duration' => 800,
]);
```
All beam options pass through transparently — refer to the AlpineFlow [Beam renderer](https://artisanflow.dev/docs/alpineflow/animation/particles#beam-renderer) docs for the full option reference.
### Bulk animation control by tag
Long-running animations (loops, ambient effects) can be tagged and then cancelled/paused/resumed as a group from the server:
```php
// Start an ambient loop, all tagged 'ambient'
$this->flowAnimate([
'nodes' => [
'pulse-1' => ['position' => ['x' => 200]],
'pulse-2' => ['position' => ['x' => 200]],
],
], [
'duration' => 3000,
'loop' => true,
'tag' => 'ambient',
]);
// …later, pause everything with that tag
$this->flowPauseAll(['tag' => 'ambient']);
$this->flowResumeAll(['tag' => 'ambient']);
// Or cancel with a specific stop mode
$this->flowCancelAll(['tag' => 'ambient'], ['mode' => 'rollback']);
```
`$filter` accepts `['tag' => 'name']` or `['tags' => ['a', 'b']]`. `$options` for `flowCancelAll` accepts `['mode' => 'jump-end' | 'rollback' | 'freeze']` — see the AlpineFlow [stop modes](https://artisanflow.dev/docs/alpineflow/animation/animate#stop-modes) reference for behavior.
### flowHighlightPath option pass-through (bug fix)
Prior to v0.2.0-alpha, `flowHighlightPath()` silently dropped any option other than `color`/`size`/`duration`/`delay`. This is now fixed — all particle options pass through, so you can use the beam renderer with gradients along a highlighted path:
```php
$this->flowHighlightPath(['start', 'process', 'review', 'done'], [
'renderer' => 'beam',
'length' => 50,
'gradient' => [
['offset' => 0, 'color' => '#8b5cf6', 'opacity' => 0],
['offset' => 1, 'color' => '#fff', 'opacity' => 1],
],
'duration' => 900,
'delay' => 200,
]);
```
::demo
```toolbar
```
```html
```
::enddemo
## Related
- [Update & Animate](basics.md) -- flowUpdate and flowAnimate
- [Timeline](timeline.md) -- multi-step animations via Alpine
- [Camera Control](camera.md) -- focus and follow nodes
- [Convenience Methods](../server/convenience.md) -- flowHighlightPath details
# Camera Control
WireFlow provides server-side methods to control the viewport camera: `flowFocusNode()` pans and zooms to center on a specific node, while `flowFollow()` / `flowUnfollow()` keep the camera tracking a node as it moves. The `x-flow-follow` directive offers a client-side alternative for follow mode.
::demo
```toolbar
```
```html
```
::enddemo
## flowFocusNode
Pan and zoom the viewport to center on a specific node. Defaults to a 300ms smooth transition:
```php
// Default smooth focus
$this->flowFocusNode('step-2');
```
### Options
```php
$this->flowFocusNode('step-2', duration: 600, padding: 100);
```
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `$id` | `string` | (required) | Target node ID |
| `$duration` | `?int` | `300` | Transition duration in ms. Pass `0` for instant. |
| `$padding` | `float` | `50` | Padding around the node in pixels |
### Use cases
Focus after creating a new node:
```php
public function addNode(): void
{
$id = 'node-' . uniqid();
$this->flowAddNodes([
[
'id' => $id,
'position' => ['x' => 800, 'y' => 400],
'data' => ['label' => 'New Node'],
],
]);
$this->flowFocusNode($id, duration: 400);
}
```
Focus on the next step in a workflow:
```php
public function approve(string $stepId): void
{
$nextStep = $this->getNextStep($stepId);
$this->flowLockNode($stepId);
$this->flowHighlightNode($stepId, 'success');
$this->flowFocusNode($nextStep, duration: 500);
}
```
Click "Focus" buttons to pan/zoom to each node, or "Follow" to track a node as you drag it:
::demo
```toolbar
```
```html
```
::enddemo
## flowFollow / flowUnfollow
Make the camera track a node continuously. As the node moves (via drag, animation, or server updates), the viewport stays centered on it:
```php
// Start following
$this->flowFollow('active-node');
// Follow with options
$this->flowFollow('active-node', [
'zoom' => 1.5, // Lock zoom level while following
'speed' => 0.1, // Camera smoothing (0-1, lower = smoother)
'padding' => 80, // Padding around the node
]);
// Stop following
$this->flowUnfollow();
```
### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `zoom` | `float` | Current zoom | Lock zoom level while following |
| `speed` | `float` | `0.15` | Camera smoothing factor (0 = very smooth, 1 = instant) |
| `padding` | `float` | `50` | Padding around the tracked node |
### Example: Follow a processing node
```php
'a', 'position' => ['x' => 0, 'y' => 0], 'data' => ['label' => 'Step A']],
['id' => 'b', 'position' => ['x' => 400, 'y' => 0], 'data' => ['label' => 'Step B']],
['id' => 'c', 'position' => ['x' => 800, 'y' => 0], 'data' => ['label' => 'Step C']],
['id' => 'd', 'position' => ['x' => 400, 'y' => 300], 'data' => ['label' => 'Step D']],
];
public array $edges = [
['id' => 'e-a-b', 'source' => 'a', 'target' => 'b'],
['id' => 'e-b-c', 'source' => 'b', 'target' => 'c'],
['id' => 'e-b-d', 'source' => 'b', 'target' => 'd'],
];
#[Renderless]
public function focusA(): void
{
$this->flowFocusNode('a', duration: 500);
}
#[Renderless]
public function focusC(): void
{
$this->flowFocusNode('c', duration: 500, padding: 100);
}
#[Renderless]
public function focusD(): void
{
$this->flowFocusNode('d', duration: 500);
}
#[Renderless]
public function followB(): void
{
$this->flowFollow('b', [
'zoom' => 1.2,
'speed' => 0.1,
]);
}
#[Renderless]
public function stopFollow(): void
{
$this->flowUnfollow();
}
public function render()
{
return view('livewire.follow-demo');
}
}
```
```blade
{{-- resources/views/livewire/follow-demo.blade.php --}}
```
## x-flow-follow directive
For client-side follow mode that does not require a server round-trip, use the `x-flow-follow` directive directly in your Blade template. Bind it to an Alpine expression that evaluates to a node ID (or `null` to stop following):
```blade
{{-- Follow mode driven by Alpine state --}}
Following:
```
The directive watches the bound expression reactively. When it changes to a new node ID, the camera starts tracking that node. When it changes to `null`, following stops.
## Combining focus and follow
A common pattern is to focus on a node first, then start following it:
```php
#[Renderless]
public function trackNode(string $id): void
{
// Smooth pan to the node first
$this->flowFocusNode($id, duration: 400);
// Then start following
$this->flowFollow($id, [
'zoom' => 1.3,
'speed' => 0.1,
]);
}
```
## Related
- [Update & Animate](basics.md) -- flowUpdate and flowAnimate
- [Timeline](timeline.md) -- multi-step animations via Alpine
- [Particles](particles.md) -- fire particles along edges
- [WithWireFlow Trait](../server/trait.md) -- all viewport methods
# WithWireFlow Trait
The `WithWireFlow` trait gives your Livewire component methods to control the flow canvas from the server: dispatch viewport changes, add or remove nodes and edges, manage layout, undo/redo history, and collapse/expand groups.
## Setup
Add the trait to any Livewire component that uses ``:
```php
'1', 'position' => ['x' => 0, 'y' => 0], 'data' => ['label' => 'Start']],
['id' => '2', 'position' => ['x' => 250, 'y' => 100], 'data' => ['label' => 'End']],
];
public array $edges = [
['id' => 'e1', 'source' => '1', 'target' => '2'],
];
public function render()
{
return view('livewire.flow-editor');
}
}
```
All `flow*` methods dispatch Livewire events that the AlpineFlow bridge picks up and executes on the client.
## Viewport
| Method | Description |
|--------|-------------|
| `$this->flowFitView()` | Fit all nodes in viewport |
| `$this->flowZoomIn()` | Zoom in one step |
| `$this->flowZoomOut()` | Zoom out one step |
| `$this->flowSetCenter(float $x, float $y, ?float $zoom)` | Pan/zoom to coordinates |
| `$this->flowSetViewport(array $viewport)` | Set viewport `['x' => 0, 'y' => 0, 'zoom' => 1]` |
| `$this->flowPanBy(float $dx, float $dy)` | Pan by offset |
| `$this->flowFitBounds(array $rect, array $options)` | Fit specific bounds `['x', 'y', 'width', 'height']` |
| `$this->flowToggleInteractive()` | Toggle pan/zoom/drag on the entire canvas |
### Viewport examples
```php
// Fit all content with padding
$this->flowFitView();
// Zoom to a specific region
$this->flowFitBounds(
['x' => 0, 'y' => 0, 'width' => 500, 'height' => 300],
['padding' => 50],
);
// Center on a coordinate
$this->flowSetCenter(250, 150, zoom: 1.5);
// Nudge the viewport
$this->flowPanBy(100, 0); // Pan 100px right
```
## Nodes & Edges
| Method | Description |
|--------|-------------|
| `$this->flowAddNodes(array $nodes)` | Add nodes to the canvas. Appends to server-side `$this->nodes` and dispatches to client. |
| `$this->flowRemoveNodes(array $ids)` | Remove nodes from the canvas. Cascade-removes descendants (via parentId) and connected edges from server-side `$this->nodes` / `$this->edges`, then dispatches to client. |
| `$this->flowAddEdges(array $edges)` | Add edges to the canvas. Appends to server-side `$this->edges` and dispatches to client. |
| `$this->flowRemoveEdges(array $ids)` | Remove edges by ID. Removes from server-side `$this->edges` and dispatches to client. |
| `$this->flowClear()` | Remove all nodes and edges and reset the viewport to the origin. Destructive — dispatches `flow:clear`, which the canvas applies immediately. |
| `$this->flowDeselectAll()` | Deselect everything |
### Adding nodes and edges
```php
public function addStep(): void
{
$this->flowAddNodes([
[
'id' => 'step-' . uniqid(),
'position' => ['x' => 300, 'y' => 200],
'data' => ['label' => 'New Step'],
],
]);
}
public function connectSteps(string $from, string $to): void
{
$this->flowAddEdges([
[
'id' => "e-{$from}-{$to}",
'source' => $from,
'target' => $to,
],
]);
}
public function removeStep(string $id): void
{
$this->flowRemoveNodes([$id]);
}
public function reset(): void
{
$this->flowClear();
}
```
## Update & Animation
| Method | Description |
|--------|-------------|
| `$this->flowUpdate(array $targets, array $options)` | Update nodes/edges/viewport instantly |
| `$this->flowAnimate(array $targets, array $options)` | Animate nodes/edges/viewport (300ms smooth default) |
| `$this->flowSendParticle(string $edgeId, array $options)` | Fire one particle along an edge |
| `$this->flowSendParticleAlongPath(string $path, array $options)` | Fire a particle along an arbitrary SVG path string — no edge required |
| `$this->flowSendParticleBetween(string $source, string $target, array $options)` | Fire a particle on the straight line between two node centers — no edge required |
| `$this->flowSendParticleBurst(string $edgeId, array $options)` | Fire `count` staggered particles along one edge |
| `$this->flowSendConverging(array $sourceEdgeIds, array $options)` | Fan-in: fire particles on multiple edges with synchronized arrival (or start) |
| `$this->flowCancelAll(array $filter, array $options)` | Cancel all animations matching `['tag' => …]` or `['tags' => […]]`. `options.mode`: `freeze` \| `rollback` \| `jump-end` |
| `$this->flowPauseAll(array $filter)` | Pause all animations matching the tag filter |
| `$this->flowResumeAll(array $filter)` | Resume paused animations matching the tag filter |
| `$this->flowFollow(string $nodeId, array $options)` | Camera follows a node |
| `$this->flowUnfollow()` | Stop following |
See [Update & Animate](../animation/basics.md), [Particles](../animation/particles.md), and [Camera Control](../animation/camera.md) for detailed usage.
## Layout & State
| Method | Description |
|--------|-------------|
| `$this->flowLayout(array $options)` | Apply auto-layout (dagre/elk) |
| `$this->flowFromObject(array $data)` | Load entire flow from serialized data |
| `$this->flowSetLoading(bool $loading)` | Show/hide loading overlay |
| `$this->flowPatchConfig(array $changes)` | Update config at runtime |
### Layout example
```php
public function autoLayout(): void
{
$this->flowLayout([
'direction' => 'TB', // Top-to-bottom
'spacing' => [80, 60], // [horizontal, vertical] gap
]);
}
public function loadSaved(int $diagramId): void
{
$this->flowSetLoading(true);
$diagram = Diagram::findOrFail($diagramId);
$this->flowFromObject([
'nodes' => $diagram->nodes,
'edges' => $diagram->edges,
]);
$this->flowSetLoading(false);
$this->flowFitView();
}
```
## History
| Method | Description |
|--------|-------------|
| `$this->flowUndo()` | Undo last change (requires `history: true`) |
| `$this->flowRedo()` | Redo last undone change |
Enable history on the canvas component:
```blade
```
```php
public function undo(): void
{
$this->flowUndo();
}
public function redo(): void
{
$this->flowRedo();
}
```
## Workflow & RunState
| Method | Description |
|--------|-------------|
| `$this->flowRun(string $startId, array $options)` | Trigger a `$flow.run()` on the canvas, starting from `$startId`. Server-side handlers are not exposed — pass `options` (e.g. `payload`, `defaultDurationMs`, `particleOnEdges`) and define handlers in client-side JS via the workflow addon. |
| `$this->flowSetNodeState(string\|array $ids, string $state)` | Set `runState` on one or more nodes. Auto-syncs server-side `$nodes`. Valid states: `pending`, `running`, `completed`, `failed`, `skipped` |
| `$this->flowResetStates()` | Clear `runState` from all nodes. Auto-syncs server-side `$nodes` |
These methods require the [workflow addon](../addons/workflow.md) to be loaded on the client. The addon also ships the ``, ``, ``, ``, and `` components that pair with these trait methods.
### RunState example
```php
// Mark nodes as running when a job starts
public function startWorkflow(): void
{
$this->flowSetNodeState(['step-1', 'step-2'], 'running');
}
// Mark a node completed when a step finishes
#[On('step-completed')]
public function onStepCompleted(string $nodeId): void
{
$this->flowSetNodeState($nodeId, 'completed');
}
// Reset everything for a fresh run
public function resetWorkflow(): void
{
$this->flowResetStates();
}
```
The theme provides default visual treatments (violet pulse for `running`, teal flash for `completed`, red pulse for `failed`, dimmed for `skipped`). Override via `--flow-node-running-border-color` and related CSS variables on `.flow-container`.
## Collapse/Expand
| Method | Description |
|--------|-------------|
| `$this->flowCollapseNode(string $id)` | Collapse a group node, hiding its children |
| `$this->flowExpandNode(string $id)` | Expand a collapsed group node |
| `$this->flowToggleNode(string $id)` | Toggle collapse/expand |
```php
public function toggleGroup(string $groupId): void
{
$this->flowToggleNode($groupId);
}
```
## Using #[Renderless]
For methods that only dispatch flow commands without changing Livewire state, use the `#[Renderless]` attribute to skip the re-render cycle. This improves performance for fire-and-forget actions.
> **Livewire 3:** The `#[Renderless]` attribute requires Livewire 3.3+. On older Livewire 3.x versions, call `$this->skipRender()` at the end of the method instead.
```php
use Livewire\Attributes\Renderless;
class FlowEditor extends Component
{
use WithWireFlow;
#[Renderless]
public function zoomToFit(): void
{
$this->flowFitView();
}
#[Renderless]
public function centerOnNode(string $id): void
{
$this->flowSetCenter(250, 150, zoom: 1.2);
}
#[Renderless]
public function toggleLock(): void
{
$this->flowToggleInteractive();
}
}
```
The corresponding Blade template wires these to buttons:
```blade
```
::demo
```toolbar
```
```html
```
::enddemo
## Related
- [Convenience Methods](convenience.md) -- simplified methods for common operations
- [Event Handlers](events.md) -- handle canvas events on the server
- [Server Patterns](patterns.md) -- complete working patterns and recipes
# Convenience Methods
The `WithWireFlow` trait includes convenience methods that wrap common multi-step operations into single calls. These cover moving nodes, focusing the camera, creating connections, visual feedback, and visibility/lock state.
## All convenience methods
| Method | Description |
|--------|-------------|
| `flowUpdate(array $targets, array $options)` | Update nodes/edges/viewport instantly |
| `flowAnimate(array $targets, array $options)` | Animate nodes/edges/viewport with smooth transitions (300ms default) |
| `flowMoveNode(string $id, float $x, float $y, ?int $duration)` | Move a single node. Instant by default, pass `duration` for smooth transition. |
| `flowUpdateNode(string $id, array $changes, ?int $duration)` | Update any node properties (position, data, style, class, etc.) |
| `flowFocusNode(string $id, ?int $duration, float $padding)` | Pan and zoom to center on a specific node. Defaults to 300ms smooth. |
| `flowConnect(string $source, string $target, ?int $duration, ?string $edgeId, array $options)` | Create an edge between two nodes. Pass `duration` for draw-in animation. Auto-generates edge ID if omitted. |
| `flowDisconnect(string $source, string $target, ?int $duration)` | Remove edge(s) between two nodes. Pass `duration` for fade-out animation. |
| `flowHighlightNode(string $id, string $style, ?int $duration)` | Flash a preset visual state then revert. Presets: `'success'`, `'error'`, `'warning'`, `'info'`. Default: 1500ms. |
| `flowHighlightPath(array $nodeIds, array $options)` | Fire particles along edges connecting a sequence of nodes. Accepts any particle option (`renderer`, `color`, `size`, `duration`, `delay`, `gradient`, `length`, `width`, `easing`, …). |
| `flowLockNode(string $id)` | Lock a node (prevent drag, show dashed border) |
| `flowUnlockNode(string $id)` | Unlock a node |
| `flowHideNode(string $id)` | Hide a node from rendering |
| `flowShowNode(string $id)` | Show a hidden node |
| `flowSelectNodes(array $ids)` | Select specific nodes (deselects all others first) |
| `flowSelectEdges(array $ids)` | Select specific edges (deselects all others first) |
## flowMoveNode
Move a single node to new coordinates. Pass `duration` in milliseconds for a smooth transition, or omit for an instant move.
```php
// Instant move
$this->flowMoveNode('step-3', 400, 200);
// Smooth move over 500ms
$this->flowMoveNode('step-3', 400, 200, duration: 500);
```
## flowUpdateNode
Update any combination of node properties. Merges with existing values.
```php
// Update label and style
$this->flowUpdateNode('step-1', [
'data' => ['label' => 'Updated Label', 'status' => 'complete'],
'class' => 'bg-green-100 border-green-500',
]);
// Animate position + data change over 400ms
$this->flowUpdateNode('step-1', [
'position' => ['x' => 500, 'y' => 100],
'data' => ['label' => 'Moved!'],
], duration: 400);
```
## flowFocusNode
Pan and zoom the viewport to center on a specific node. Defaults to a 300ms smooth transition.
```php
// Default smooth focus
$this->flowFocusNode('step-2');
// Custom duration and padding
$this->flowFocusNode('step-2', duration: 600, padding: 100);
```
## flowConnect / flowDisconnect
Create or remove edges between nodes. Optionally animate the transition.
```php
// Create edge instantly
$this->flowConnect('step-1', 'step-2');
// Create edge with draw-in animation and custom ID
$this->flowConnect('step-1', 'step-2', duration: 600, edgeId: 'approval-edge');
// Create edge with additional options
$this->flowConnect('step-1', 'step-2', duration: 400, options: [
'type' => 'smoothstep',
'animated' => true,
]);
// Remove edge(s) between nodes
$this->flowDisconnect('step-1', 'step-2');
// Remove with fade-out animation
$this->flowDisconnect('step-1', 'step-2', duration: 300);
```
## flowHighlightNode
Flash a preset visual style on a node, then automatically revert. Four presets are available:
| Preset | Visual |
|--------|--------|
| `'success'` | Green glow |
| `'error'` | Red glow |
| `'warning'` | Amber glow |
| `'info'` | Blue glow |
```php
// Default 1500ms highlight
$this->flowHighlightNode('step-1', 'success');
// Custom duration
$this->flowHighlightNode('step-1', 'error', duration: 3000);
```
## flowHighlightPath
Fire particles along edges connecting a sequence of nodes, creating a cascading trail effect. Options are passed through to each particle, so any `$flow.sendParticle()` option works — renderer, gradient, beam geometry, easing, etc.
```php
// Simple path highlight
$this->flowHighlightPath(['step-1', 'step-2', 'step-3']);
// Customized particles
$this->flowHighlightPath(['step-1', 'step-2', 'step-3'], [
'color' => '#22c55e',
'size' => 6,
'duration' => 1000,
'delay' => 200, // Delay between each segment
]);
// Beam with a multi-stop gradient (all options reach the particle)
$this->flowHighlightPath(['step-1', 'step-2', 'step-3'], [
'renderer' => 'beam',
'length' => 60,
'width' => 4,
'duration' => 900,
'delay' => 220,
'gradient' => [
['offset' => 0, 'color' => '#06B6D4', 'opacity' => 0],
['offset' => 0.7, 'color' => '#D946EF', 'opacity' => 0.8],
['offset' => 1, 'color' => '#fff', 'opacity' => 1],
],
]);
```
> **v0.2.0-alpha fix:** prior versions silently dropped every option other than `color` / `size` / `duration` / `delay`. Renderer, gradient, and beam options now pass through unchanged.
## flowLockNode / flowUnlockNode
Lock prevents dragging and shows a dashed border. Unlock restores normal interaction.
```php
$this->flowLockNode('step-1'); // Prevent drag, show dashed border
$this->flowUnlockNode('step-1'); // Restore normal interaction
```
## flowHideNode / flowShowNode
Toggle node visibility without removing it from the data.
```php
$this->flowHideNode('debug-panel');
$this->flowShowNode('debug-panel');
```
## flowSelectNodes / flowSelectEdges
Programmatically select nodes or edges. Deselects all others first.
```php
$this->flowSelectNodes(['step-1', 'step-3']);
$this->flowSelectEdges(['e-1-2']);
```
## Example: Workflow step approval
A complete approval workflow that locks the completed step, highlights it green, draws in the connecting edge, fires particles along the path, and focuses the next step:
```php
'draft', 'position' => ['x' => 0, 'y' => 0], 'data' => ['label' => 'Draft', 'status' => 'current']],
['id' => 'review', 'position' => ['x' => 300, 'y' => 0], 'data' => ['label' => 'Review', 'status' => 'pending']],
['id' => 'approved', 'position' => ['x' => 600, 'y' => 0], 'data' => ['label' => 'Approved', 'status' => 'pending']],
];
public array $edges = [];
#[Renderless]
public function approve(string $stepId): void
{
$nextStep = $this->getNextStep($stepId);
$this->flowLockNode($stepId);
$this->flowHighlightNode($stepId, 'success');
$this->flowConnect($stepId, $nextStep, duration: 600);
$this->flowHighlightPath([$stepId, $nextStep]);
$this->flowFocusNode($nextStep);
}
#[Renderless]
public function reject(string $stepId): void
{
$this->flowHighlightNode($stepId, 'error');
$this->flowFocusNode($stepId);
}
private function getNextStep(string $current): string
{
$order = ['draft', 'review', 'approved'];
$index = array_search($current, $order);
return $order[$index + 1] ?? $order[$index];
}
public function render()
{
return view('livewire.approval-flow');
}
}
```
```blade
{{-- resources/views/livewire/approval-flow.blade.php --}}
```
::demo
```toolbar
```
```html
```
::enddemo
## Example: Move and focus
```php
#[Renderless]
public function repositionNode(string $id, float $x, float $y): void
{
$this->flowMoveNode($id, $x, $y, duration: 500);
$this->flowFocusNode($id, duration: 400);
}
```
## Example: Remote control panel
A panel of buttons demonstrating several convenience methods working together:
```php
'a', 'position' => ['x' => 0, 'y' => 0], 'data' => ['label' => 'Node A']],
['id' => 'b', 'position' => ['x' => 300, 'y' => 0], 'data' => ['label' => 'Node B']],
['id' => 'c', 'position' => ['x' => 150, 'y' => 200], 'data' => ['label' => 'Node C']],
];
public array $edges = [
['id' => 'e-a-b', 'source' => 'a', 'target' => 'b'],
['id' => 'e-b-c', 'source' => 'b', 'target' => 'c'],
];
#[Renderless]
public function moveNodeA(): void
{
$this->flowMoveNode('a', 100, 100, duration: 600);
}
#[Renderless]
public function selectAll(): void
{
$this->flowSelectNodes(['a', 'b', 'c']);
}
#[Renderless]
public function highlightSuccess(): void
{
$this->flowHighlightNode('b', 'success');
}
#[Renderless]
public function connectAC(): void
{
$this->flowConnect('a', 'c', duration: 500);
}
#[Renderless]
public function lockB(): void
{
$this->flowLockNode('b');
}
#[Renderless]
public function unlockB(): void
{
$this->flowUnlockNode('b');
}
public function render()
{
return view('livewire.remote-control');
}
}
```
```blade
{{-- resources/views/livewire/remote-control.blade.php --}}
```
::demo
```toolbar
```
```html
```
::enddemo
## Related
- [WithWireFlow Trait](trait.md) -- setup and all base methods
- [Event Handlers](events.md) -- handle canvas events on the server
- [Update & Animate](../animation/basics.md) -- flowUpdate vs flowAnimate in depth
- [Particles](../animation/particles.md) -- flowSendParticle and flowHighlightPath details
# Event Handlers
Declare these methods on your Livewire component to handle canvas events. Wire them up via event attributes on ``:
```blade
```
Only the events you reference with `@event-name` are dispatched to the server. Omit an attribute to skip that event entirely.
## Connection events
```php
public function onConnect(
string $source, // Source node ID
string $target, // Target node ID
?string $sourceHandle, // Source handle ID (null if default)
?string $targetHandle, // Target handle ID (null if default)
): void {
// A new edge was created by the user.
// Typically you'd store the edge:
$this->edges[] = [
'id' => "e-{$source}-{$target}",
'source' => $source,
'target' => $target,
'sourceHandle' => $sourceHandle,
'targetHandle' => $targetHandle,
];
}
public function onConnectStart(
string $source, // Source node ID
?string $sourceHandle, // Source handle ID
): void {
// User started dragging from a handle
}
public function onConnectEnd(
?array $connection, // ['source', 'target', 'sourceHandle', 'targetHandle'] or null if cancelled
?string $source, // Source node ID
?string $sourceHandle, // Source handle ID
?array $position, // ['x' => float, 'y' => float] -- flow coordinates where drag ended
): void {
// Drag finished -- $connection is null if user released on empty canvas
}
```
## Connection validators
### `@connect-validate`
Runs **before** the edge is committed, unlike `@connect` which fires after. Return `true` to allow, `false` to silently reject, or `['allowed' => false, 'reason' => '...']` to reject with a user-facing toast.
```blade
```
```php
public function canConnect(
string $source,
string $target,
?string $sourceHandle,
?string $targetHandle,
?string $replacingEdgeId = null,
): bool|array {
if ($source === $target) {
return ['allowed' => false, 'reason' => 'Self-connections not supported.'];
}
return true;
}
```
`$replacingEdgeId` is the id of the edge whose end is being dragged, and `null` for a new line. It
matters to any rule that reasons about the graph as a whole — cycles, reachability, scopes — because
a reconnect asks whether this connection may exist **instead of** that one, and the old edge is still
in the graph while the question is asked:
```php
$edges = collect($this->edges)->reject(fn (array $edge): bool => $edge['id'] === $replacingEdgeId);
```
The parameter is optional and was added after the original four. A handler written without it keeps
working untouched: PHP ignores extra positional arguments to a userland method.
While the Livewire roundtrip is in flight, AlpineFlow adds the CSS class `flow-handle-validating` to the source and target handles. Style it to indicate a pending state — the default theme ships a subtle pulse. Override the class name via `config.validatingHandleClass`.
When the server rejects with a `reason`, WireFlow dispatches a `flux-toast` event (`{ variant: 'warning', text: reason }`). To suppress or customize the toast, listen for the validated DOM event and call `preventDefault()` — AlpineFlow respects it.
Two DOM events fire on the canvas for deeper customization:
- `flow-connect-validating` — `{ detail: { connection } }` fires on drop.
- `flow-connect-validated` — `{ detail: { connection, allowed, reason } }` fires when the server responds.
Attach with `@flow-connect-validating="..."` on the `` element.
## Node events
```php
public function onNodeClick(
string $nodeId, // Clicked node ID
array $node, // Full node array: ['id', 'position' => ['x','y'], 'data' => [...], ...]
): void {
// Handle node click
}
public function onNodeDragStart(string $nodeId): void {
// User started dragging a node
}
public function onNodeDragEnd(
string $nodeId,
array $position, // ['x' => float, 'y' => float] -- final flow-space position
): void {
// Update the server-side position if using sync mode
}
public function onNodeResizeStart(
string $nodeId,
array $dimensions, // ['width' => float, 'height' => float]
): void {
// User started resizing a node
}
public function onNodeResizeEnd(
string $nodeId,
array $dimensions, // ['width' => float, 'height' => float] -- final dimensions
): void {
// Persist the new dimensions if needed
}
public function onNodeCollapse(string $nodeId): void {
// A group node was collapsed
}
public function onNodeExpand(string $nodeId): void {
// A group node was expanded
}
public function onNodeReparent(
string $nodeId,
?string $newParentId, // New parent ID (null if detached)
?string $oldParentId, // Previous parent ID (null if was root)
): void {
// A node was drag-reparented into or out of a group
}
public function onNodeContextMenu(
string $nodeId,
array $screenPosition, // ['x' => float, 'y' => float] -- screen coordinates
): void {
// Right-click on a node -- use screenPosition to place a context menu
}
public function onNodesChange(array $changes): void {
// Batch notification of node additions or removals
// $changes = ['type' => 'add'|'remove', 'nodes' => [['id' => '...', ...], ...],
// 'origin' => 'drop'|'paste'|'api'|'load']
// Filter on origin to persist only user intent, e.g.:
// if (($changes['origin'] ?? null) === 'drop') { /* the user dropped a node */ }
}
```
## Edge events
```php
public function onEdgeClick(string $edgeId): void {
// User clicked an edge
}
public function onEdgeContextMenu(
string $edgeId,
array $screenPosition, // ['x' => float, 'y' => float]
): void {
// Right-click on an edge
}
public function onEdgesChange(array $changes): void {
// Batch notification of edge additions or removals
// $changes = ['type' => 'add'|'remove', 'edges' => [['id' => '...', ...], ...],
// 'origin' => 'drop'|'paste'|'api'|'load']
}
public function onReconnect(
string $oldEdgeId,
array $newConnection, // ['source', 'target', 'sourceHandle', 'targetHandle']
): void {
// An edge endpoint was dragged to a new node
}
public function onReconnectStart(
string $edgeId,
string $handleType, // 'source' or 'target' -- which end is being reconnected
): void {
// User started reconnecting an edge
}
public function onReconnectEnd(
string $edgeId,
bool $successful, // true if reconnected, false if cancelled
): void {
// Reconnect attempt finished
}
```
## Canvas events
```php
public function onPaneClick(array $position): void {
// Clicked empty canvas area
// $position = ['x' => 100, 'y' => 200] (flow coordinates)
}
public function onPaneContextMenu(array $position): void {
// Right-click on empty canvas
}
public function onViewportChange(array $viewport): void {
// Viewport panned or zoomed
// $viewport = ['x' => 0, 'y' => 0, 'zoom' => 1.0]
}
```
## Selection events
```php
public function onSelectionChange(array $nodes, array $edges): void {
// Selection changed
// $nodes = ['node-1', 'node-2'] -- selected node IDs
// $edges = ['edge-1'] -- selected edge IDs
}
public function onSelectionContextMenu(
array $nodes,
array $edges,
array $screenPosition, // ['x' => float, 'y' => float]
): void {
// Right-click while nodes/edges are selected
}
```
## Row events
```php
public function onRowSelect(string $nodeId, string $attrId): void {
// A row attribute was selected on a node
}
public function onRowDeselect(string $nodeId, string $attrId): void {
// A row attribute was deselected
}
public function onRowSelectionChange(array $rows): void {
// Row selection changed
// $rows = ['node1.attr1', 'node2.attr3']
}
```
## Other events
```php
public function onDrop(array $data): void {
// External drag-and-drop onto the canvas
}
public function onInit(array $data): void {
// Canvas finished initializing -- safe to call flow commands
}
```
## Complete example
A component that persists drag positions and handles connections:
```php
nodes = FlowNode::all()->map(fn ($n) => [
'id' => (string) $n->id,
'position' => ['x' => $n->x, 'y' => $n->y],
'data' => ['label' => $n->label],
])->toArray();
$this->edges = FlowEdge::all()->map(fn ($e) => [
'id' => (string) $e->id,
'source' => (string) $e->source_id,
'target' => (string) $e->target_id,
])->toArray();
}
public function onNodeDragEnd(string $nodeId, array $position): void
{
FlowNode::where('id', $nodeId)->update([
'x' => $position['x'],
'y' => $position['y'],
]);
}
public function onConnect(string $source, string $target, ?string $sourceHandle, ?string $targetHandle): void
{
$edge = FlowEdge::create([
'source_id' => $source,
'target_id' => $target,
]);
$this->edges[] = [
'id' => (string) $edge->id,
'source' => $source,
'target' => $target,
];
}
public function onPaneClick(array $position): void
{
$this->flowDeselectAll();
}
public function render()
{
return view('livewire.persistent-flow');
}
}
```
```blade
{{-- resources/views/livewire/persistent-flow.blade.php --}}
```
::enddemo
## Dashboard Monitor
A live-updating dashboard that polls the server for status changes. Nodes represent services, and their visual state updates based on health checks. Particles fire along edges to show data flow.
```php
'api', 'position' => ['x' => 0, 'y' => 100], 'data' => ['label' => 'API Gateway', 'status' => 'healthy']],
['id' => 'auth', 'position' => ['x' => 300, 'y' => 0], 'data' => ['label' => 'Auth Service', 'status' => 'healthy']],
['id' => 'db', 'position' => ['x' => 300, 'y' => 200], 'data' => ['label' => 'Database', 'status' => 'healthy']],
['id' => 'cache', 'position' => ['x' => 600, 'y' => 100], 'data' => ['label' => 'Cache', 'status' => 'healthy']],
];
public array $edges = [
['id' => 'e-api-auth', 'source' => 'api', 'target' => 'auth', 'animated' => true],
['id' => 'e-api-db', 'source' => 'api', 'target' => 'db', 'animated' => true],
['id' => 'e-auth-cache', 'source' => 'auth', 'target' => 'cache', 'animated' => true],
];
#[Renderless]
public function checkHealth(HealthChecker $health): void
{
$statuses = $health->checkAll();
foreach ($statuses as $serviceId => $status) {
$this->flowUpdateNode($serviceId, [
'data' => ['status' => $status],
]);
if ($status === 'degraded') {
$this->flowHighlightNode($serviceId, 'warning');
} elseif ($status === 'down') {
$this->flowHighlightNode($serviceId, 'error');
}
}
// Show data flow with particles
$this->flowSendParticle('e-api-auth', ['color' => '#3b82f6', 'size' => 4]);
$this->flowSendParticle('e-api-db', ['color' => '#3b82f6', 'size' => 4]);
}
public function render()
{
return view('livewire.dashboard-monitor');
}
}
```
```blade
{{-- resources/views/livewire/dashboard-monitor.blade.php --}}
```
::demo
```toolbar
Events: 0
```
```html
```
::enddemo
## Dynamic Node Creation
Nodes are added from the server on button click. Connections are persisted via the `onConnect` handler. Each new node gets focused with a smooth camera pan.
```php
'start', 'position' => ['x' => 0, 'y' => 0], 'data' => ['label' => 'Start']],
];
public array $edges = [];
public int $nodeCount = 1;
public function addNode(string $label = 'New Node'): void
{
$this->nodeCount++;
$id = 'node-' . $this->nodeCount;
// Stagger new nodes vertically
$x = ($this->nodeCount - 1) * 250;
$y = ($this->nodeCount % 2 === 0) ? 100 : 0;
$this->flowAddNodes([
[
'id' => $id,
'position' => ['x' => $x, 'y' => $y],
'data' => ['label' => $label],
],
]);
$this->flowFocusNode($id, duration: 400);
$this->flowHighlightNode($id, 'info');
}
#[Renderless]
public function addAndConnect(string $fromId): void
{
$this->nodeCount++;
$id = 'node-' . $this->nodeCount;
$x = ($this->nodeCount - 1) * 250;
$y = 0;
$this->flowAddNodes([
[
'id' => $id,
'position' => ['x' => $x, 'y' => $y],
'data' => ['label' => 'Step ' . $this->nodeCount],
],
]);
$this->flowConnect($fromId, $id, duration: 500);
$this->flowFocusNode($id, duration: 400);
}
public function onConnect(string $source, string $target, ?string $sourceHandle, ?string $targetHandle): void
{
$this->edges[] = [
'id' => "e-{$source}-{$target}",
'source' => $source,
'target' => $target,
'sourceHandle' => $sourceHandle,
'targetHandle' => $targetHandle,
];
}
public function render()
{
return view('livewire.dynamic-builder');
}
}
```
```blade
{{-- resources/views/livewire/dynamic-builder.blade.php --}}
```
::demo
```toolbar
```
```html
```
::enddemo
## Related
- [WithWireFlow Trait](trait.md) -- setup and base methods
- [Convenience Methods](convenience.md) -- all convenience methods
- [Event Handlers](events.md) -- handle canvas events on the server
- [Update & Animate](../animation/basics.md) -- smooth transitions
# Configuration
## Publishing the config
```bash
php artisan vendor:publish --tag=wireflow-config
```
This creates `config/wireflow.php`.
## Options
### inject_alpineflow
```php
'inject_alpineflow' => true,
```
| Value | Behavior |
|-------|----------|
| `true` (default) | WireFlow expects AlpineFlow JS/CSS to be imported via Vite from the vendor path |
| `false` | Disables any auto-injection. Use this if you install `@getartisanflow/alpineflow` via npm and register it yourself. WireFlow Blade components still work. |
### theme
```php
'theme' => 'default',
```
| Value | CSS File | Description |
|-------|----------|-------------|
| `'default'` | `alpineflow-theme.css` | Neutral zinc/slate theme with light and dark mode |
| `'flux'` | `alpineflow-theme-flux.css` | Flux UI / Tailwind v4 native theme using `--color-accent` tokens |
| `'structural'` | — | Structural CSS only, no visual theme. Bring your own styles. |
| `'none'` | — | No CSS at all. You manage all imports manually. |
## Full config file
```php
true,
/*
|--------------------------------------------------------------------------
| Theme
|--------------------------------------------------------------------------
|
| Which theme CSS to use: 'default' (zinc/slate), 'flux' (Tailwind v4 /
| Flux UI native), 'structural' (layout only, no visual styling), or
| 'none' (no CSS — you manage imports manually).
|
*/
'theme' => 'default',
];
```
## Passing AlpineFlow config
The [``](components/flow.md) component promotes the most common options to named props. For everything else, use the `config` prop:
```blade
```
The `config` array is merged last, overriding any prop-derived values.
### Common config options
These are the most frequently used options you can pass via the `config` prop. All have sensible defaults — you only set what you need to change.
**Connections:**
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `isValidConnection` | `callback` | — | Custom validator: `WireFlow::js('(conn) => conn.source !== conn.target')`. Return `false` to reject. |
| `connectOnClick` | `bool` | `true` | Click source handle, then click target handle to connect. |
| `connectionSnapRadius` | `int` | `20` | Pixel radius for snapping to nearby handles. `0` disables. |
| `connectionMode` | `string` | `'strict'` | `'strict'` = source→target only. `'loose'` = any handle to any handle. |
| `multiConnect` | `bool` | `false` | Drag from one handle creates connections from ALL selected nodes. |
| `easyConnect` | `bool` | `false` | Hold Alt and drag from node body to connect. |
| `proximityConnect` | `bool` | `false` | Auto-create edges when dragging nodes near each other. |
**Node behavior:**
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `snapToGrid` | `array\|false` | `false` | Snap to grid: `[20, 20]` for 20px grid. |
| `helperLines` | `bool\|array` | `false` | Show alignment guides during drag. `true` for defaults. |
| `preventOverlap` | `bool\|int` | `false` | Prevent nodes from overlapping. Pass number for gap in px. |
| `nodeExtent` | `array` | — | Position boundaries: `[[-500, -500], [500, 500]]`. |
| `nodeDragThreshold` | `int` | `0` | Minimum px distance before drag starts. |
| `elevateNodesOnSelect` | `bool` | `true` | Selected nodes render above others. |
| `reconnectOnDelete` | `bool` | `false` | Auto-bridge connections when deleting middle nodes. |
**Selection:**
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `selectionMode` | `string` | `'partial'` | `'partial'` = any overlap. `'full'` = entire node inside box. |
| `selectionOnDrag` | `bool` | `false` | Selection box on plain drag (pair with `'panOnDrag' => [2]`). |
| `selectionTool` | `string` | `'box'` | `'box'` or `'lasso'`. |
**Viewport:**
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `panOnScroll` | `bool` | `false` | Scroll to pan instead of zoom. Ctrl+scroll zooms. |
| `translateExtent` | `array` | — | Pan boundaries: `[[-1000, -1000], [1000, 1000]]`. |
| `viewportCulling` | `bool` | `true` | Only render visible nodes (performance). |
| `zoomOnDoubleClick` | `bool` | `true` | Double-click to zoom in. |
| `autoPanOnNodeDrag` | `bool` | `true` | Auto-pan when dragging near canvas edge. |
**Drop zone:**
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `onDrop` | `callback` | — | Handle drops from external drag sources. Return a node array to add it. |
| `onEdgeDrop` | `callback` | — | Handle connection drops on empty canvas. Return a node to auto-create. |
For the complete list of 120+ options, see the [AlpineFlow configuration reference](https://artisanflow.dev/docs/alpineflow/configuration).
## Runtime config updates
From the server, use the `WithWireFlow` trait to patch config at runtime:
```php
$this->flowPatchConfig([
'pannable' => false,
'zoomable' => false,
]);
```
See [WithWireFlow Trait](server/trait.md#layout--state) for details.
## Related
- [Installation](getting-started/installation.md) — importing CSS and JS
- [x-flow component](components/flow.md) — full props reference
- [AlpineFlow Configuration](https://artisanflow.dev/docs/alpineflow/configuration) — full config reference
# WithSchemaDesigner Trait
`WithSchemaDesigner` is the server-side mirror of the [AlpineFlow Schema addon's](https://artisanflow.dev/docs/alpineflow/addons/schema) field CRUD. It gives your Livewire component four methods — `addField`, `renameField`, `removeField`, `removeNode` — that mutate `$this->nodes` / `$this->edges` with the same cascade semantics the client addon uses, and dispatch `flow:update` / `flow:fromObject` so the canvas stays in sync.
Use it alongside [`WithWireFlow`](../server/trait.md) whenever the authoritative schema lives on the server (for example, when you persist to a database and want server-side validation before the client sees a change).
## Setup
The component must expose public `nodes` and `edges` arrays. Pair the trait with `WithWireFlow` so the usual flow control methods (undo, fit view, etc.) are available too:
```php
'user',
'position' => ['x' => 0, 'y' => 0],
'data' => [
'label' => 'User',
'fields' => [
['name' => 'id', 'type' => 'uuid', 'key' => 'primary'],
['name' => 'email', 'type' => 'text', 'required' => true],
],
],
],
];
public array $edges = [];
public function render()
{
return view('livewire.schema-editor');
}
}
```
The matching Blade template mounts `` bound to these properties:
```blade
```
## Method reference
Each method mutates `$this->nodes` / `$this->edges` in place, dispatches a client event, and returns an array describing the outcome. None of them throw.
### `addField(string $nodeId, array $field): array`
Append a field to a node. Silently no-ops on duplicate name or invalid name.
```php
$result = $this->addField('user', [
'name' => 'avatar_url',
'type' => 'text',
]);
// → ['applied' => true]
```
- **Extra field keys** — `key`, `required`, `icon` (and any custom keys) pass through untouched.
- **Dispatches** — `flow:update` with the node's new `data.fields`.
- **Returns** — `['applied' => bool, 'reason' => string?]`. Reasons: `invalid-name`, `no-node`, `duplicate`.
### `renameField(string $nodeId, string $oldName, string $newName): array`
Rename a field in place. Cascades every edge whose `sourceHandle` / `targetHandle` references `(nodeId, oldName)`, rewriting the handle to `newName`.
```php
$result = $this->renameField('user', 'team_id', 'organization_id');
// → ['applied' => true, 'cascadedEdgeIds' => ['e-user-team']]
```
- **Dispatches** — `flow:fromObject` with the updated nodes + edges (a single atomic swap so the client re-measures handles on the new name).
- **Returns** — `['applied' => bool, 'reason' => string?, 'cascadedEdgeIds' => array?]`. Reasons: `unchanged`, `invalid-name`, `no-node`, `no-field`, `duplicate`.
### `removeField(string $nodeId, string $fieldName): array`
Drop a field. Cascade-drops every edge that references it as `sourceHandle` / `targetHandle`.
```php
$result = $this->removeField('user', 'team_id');
// → ['applied' => true, 'droppedEdgeIds' => ['e-user-team']]
```
- **Dispatches** — `flow:update` with the node's new `data.fields`. Cascaded edges are removed server-side before dispatch.
- **Returns** — `['applied' => bool, 'reason' => string?, 'droppedEdgeIds' => array?]`. Reasons: `no-node`, `no-field`.
### `removeNode(string $nodeId): array`
Drop a node entirely, cascading all edges that touch it.
```php
$result = $this->removeNode('team');
// → ['applied' => true, 'droppedEdgeIds' => ['e-user-team', 'e-team-owner']]
```
- **Dispatches** — `flow:fromObject` with the updated nodes + edges.
- **Returns** — `['applied' => bool, 'reason' => string?, 'droppedEdgeIds' => array?]`. Reasons: `no-node`.
## Reason values
Every non-applied return carries a `reason` string. Use it to route to UI feedback:
| Reason | Meaning |
|--------|---------|
| `invalid-name` | Name failed the [`SchemaFieldName`](#schemafieldname) validator (empty, longer than 40 chars, or does not match `/^[a-z][a-z0-9_]*$/`). |
| `no-node` | The `$nodeId` argument does not exist in `$this->nodes`. |
| `no-field` | No field with the given name exists on the specified node. |
| `duplicate` | A field with the new name already exists on the node. |
| `unchanged` | `renameField` called with `$oldName === $newName`. |
## Silent-fail discipline
These methods never throw. They return `['applied' => false, 'reason' => ...]` so Livewire control flow stays clean — you can call them from component actions without wrapping in try/catch.
If you want to surface validation errors in the UI, inspect the return value and route to a toast, a banner, or `addError()`:
```php
public function addField(string $nodeId, string $name, string $type): void
{
$result = $this->addField($nodeId, ['name' => $name, 'type' => $type]);
if (! $result['applied']) {
$this->dispatch('notify', [
'type' => 'error',
'message' => match ($result['reason']) {
'invalid-name' => "Field name must be lowercase letters, digits, and underscores.",
'duplicate' => "A field named '{$name}' already exists.",
'no-node' => "That table no longer exists.",
default => "Couldn't add the field.",
},
]);
}
}
```
> Heads up — the trait method and any wrapper you write can share the same name. In the snippet above the wrapper `addField(string, string, string)` has a different signature than the trait's `addField(string, array)`, so PHP resolves the wrapper. Pick different names if you prefer to avoid shadowing.
## Complete example
A Livewire component that renders a schema designer and surfaces cascade results via a Flux toast:
```php
addField($nodeId, [
'name' => $name,
'type' => 'text',
]);
if (! $result['applied']) {
$this->dispatch('schema-error', reason: $result['reason']);
}
}
public function renameFieldWithCascade(string $nodeId, string $old, string $new): void
{
$result = $this->renameField($nodeId, $old, $new);
if ($result['applied'] && ! empty($result['cascadedEdgeIds'])) {
$this->dispatch(
'schema-info',
message: count($result['cascadedEdgeIds']) . ' edges rewired',
);
}
}
public function render()
{
return view('livewire.schema-editor');
}
}
```
```blade
```
## Validator rules
Two PHP validation rules ship alongside the trait. Consumers can reuse them in their own Livewire validation — the trait uses them internally for `invalid-name` rejection.
### `SchemaFieldName`
Validates a single field identifier:
- Required non-empty string
- Matches `/^[a-z][a-z0-9_]*$/` — lowercase, starts with a letter, digits and underscores allowed
- Maximum 40 characters
```php
use ArtisanFlow\WireFlow\Rules\SchemaFieldName;
use Illuminate\Support\Facades\Validator;
$validator = Validator::make($request->all(), [
'name' => ['required', new SchemaFieldName],
]);
```
Matches the client addon's name validator, so server-side rejections line up with the addon's `invalid-name` result.
### `SchemaEdgeShape`
Validates the array shape of a single edge:
- Required: `source` + `target` — non-empty strings
- Optional: `id`, `sourceHandle`, `targetHandle`, `label` — strings when present
```php
use ArtisanFlow\WireFlow\Rules\SchemaEdgeShape;
$validator = Validator::make($request->all(), [
'edge' => [new SchemaEdgeShape],
]);
```
Useful when accepting edge payloads from the client before storing them.
## See also
- [``](../components/schema-designer.md) — the Blade preset that pairs with this trait
- [`` / row / edge](../components/schema-inspector.md) — the lower-level inspector wrappers
- [`WithWireFlow` trait](../server/trait.md) — general flow control
- [AlpineFlow Schema addon](https://artisanflow.dev/docs/alpineflow/addons/schema) — the client-side counterpart
# Artisan Commands
## wireflow:install
One-command setup for WireFlow. Replaces the manual steps in the [installation guide](getting-started/installation.md).
```bash
php artisan wireflow:install
```
This command:
1. **Publishes config** — copies `wireflow.php` to `config/wireflow.php`
2. **Publishes assets** — copies AlpineFlow JS/CSS to `public/vendor/alpineflow/`
3. **Adds JS import** — prepends the AlpineFlow import and `alpine:init` registration to `resources/js/app.js`
4. **Adds CSS imports** — adds structural and theme CSS imports to `resources/css/app.css`
If imports already exist in your files, they won't be duplicated.
### Options
| Flag | Description |
|------|-------------|
| `--force` | Overwrite existing config and asset files |
| `--with=*` | Install addons non-interactively. Repeat the flag for multiple addons (e.g. `--with=workflow`). |
### Addons
When you run the command interactively, WireFlow prompts you to pick optional addons to install. Each selected addon adds the matching import and `Alpine.plugin()` registration to `resources/js/app.js`.
| Addon | Description |
|-------|-------------|
| `workflow` | `$flow.run()` execution helper, condition nodes, and edge state mirroring. |
Use `--with` to skip the prompt in CI, containers, or scripts:
```bash
php artisan wireflow:install --no-interaction --with=workflow
```
Unknown addon names are warned but don't fail the command. Running `wireflow:install` again safely re-registers any missing imports without duplicating them.
### After running
```bash
npm run build
```
Then use `` in your Blade views:
```blade
```
### What it adds to app.js
```js
import AlpineFlow from '../../vendor/getartisanflow/wireflow/dist/alpineflow.bundle.esm.js';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
});
```
### What it adds to app.css
```css
@import '../../vendor/getartisanflow/wireflow/dist/alpineflow.css';
@import '../../vendor/getartisanflow/wireflow/dist/alpineflow-theme.css';
```
## Related
- [Installation](getting-started/installation.md) — manual setup steps
- [Configuration](configuration.md) — config options
# Themes
WireFlow ships with three visual themes and a structural-only mode. The active theme is set in `config/wireflow.php`.
## Theme config
```php
// config/wireflow.php
'theme' => 'default',
```
| Value | CSS File | Description |
|-------|----------|-------------|
| `'default'` | `alpineflow-theme.css` | Neutral zinc/slate palette with light and dark mode. Works with any Laravel app. |
| `'flux'` | `alpineflow-theme-flux.css` | Flux UI / Tailwind v4 native theme. Reads `--color-accent` tokens so nodes, edges, and handles match your Flux accent color automatically. |
| `'structural'` | -- | Structural CSS only. Every `--flow-*` variable falls back to its bare-minimum default (`transparent`, `none`, etc.). Use this as a blank canvas for a fully custom theme. |
| `'none'` | -- | No CSS at all. You manage all imports manually in your `app.css`. |
### What each theme provides
**Default** -- Sets background colors, border colors, shadows, handle styles, edge colors, and selection accents using a zinc/slate neutral scale. Includes `.dark` overrides for all variables.
**Flux** -- Inherits Flux UI's `--color-accent-*` CSS custom properties. Interactive states (selected borders, handle hover, edge selection) automatically match whatever accent color you configure in Flux. Dark mode uses Flux's built-in `.dark` class convention.
**Structural** -- Provides only layout and positioning rules (z-index layers, handle placement, resize grip behavior). Every visual property uses its fallback default from the structural CSS. This is the starting point when you want to theme from scratch.
## Switching themes
Change the `theme` value in `config/wireflow.php` and rebuild:
```bash
npm run build
```
The install command also accepts a theme:
```bash
php artisan wireflow:install --theme=flux
npm run build
```
## Structural-only mode for custom themes
Set `'theme' => 'structural'` and create your own theme file:
```css
/* resources/css/my-flow-theme.css */
.flow-container {
--flow-bg-color: #fafaf9;
--flow-node-bg: #ffffff;
--flow-node-border: 1px solid #d6d3d1;
--flow-node-border-radius: 8px;
--flow-node-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--flow-node-padding: 12px 16px;
--flow-handle-bg: #a8a29e;
--flow-handle-border: 2px solid #ffffff;
--flow-edge-stroke: #a8a29e;
}
.flow-container.dark {
--flow-bg-color: #1c1917;
--flow-node-bg: #292524;
--flow-node-border: 1px solid #44403c;
--flow-node-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--flow-handle-bg: #78716c;
--flow-edge-stroke: #78716c;
}
```
Import it in your `resources/css/app.css`:
```css
@import '../../vendor/getartisanflow/wireflow/dist/alpineflow.css';
@import './my-flow-theme.css';
```
## Quick brand override
To match your brand without building a full theme, override these 15 accent-related variables. Everything else derives from the neutral scale and stays consistent.
```css
.flow-container {
/* Interactive states (selection, hover, focus) */
--flow-node-hover-border-color: #7c3aed;
--flow-node-selected-border-color: #7c3aed;
--flow-node-selected-shadow: 0 0 0 1px #7c3aed;
--flow-node-focus-outline: 2px solid #7c3aed;
/* Handles */
--flow-handle-hover-bg: #7c3aed;
--flow-handle-active-bg: #7c3aed;
/* Edges */
--flow-edge-stroke-selected: #7c3aed;
--flow-edge-dot-fill: #7c3aed;
--flow-edge-focus-stroke: #7c3aed;
/* Selection box & lasso */
--flow-selection-bg: rgba(124, 58, 237, 0.06);
--flow-selection-border: 1px solid rgba(124, 58, 237, 0.3);
--flow-lasso-stroke: rgba(124, 58, 237, 0.5);
/* Resize handles */
--flow-resizer-border: 1px solid #7c3aed;
--flow-resizer-hover-bg: #7c3aed;
/* Accent stripe (top border on nodes) */
--flow-node-border-top: 2.5px solid #a78bfa;
}
```
Replace `#7c3aed` / `#a78bfa` with your brand color and its lighter variant.
### Inline override in Blade
Apply per-component overrides directly on ``:
```blade
```
## Accent stripe
The `--flow-node-border-top` variable adds a colored stripe to the top of every node. The default theme uses a blue accent (`#93c5fd`). Set it to `var(--flow-node-border)` to remove the stripe:
```css
.flow-container {
--flow-node-border-top: var(--flow-node-border);
}
```
Or use different colors per node type with CSS classes:
```blade
```
```css
.flow-node.success-node {
--flow-node-border-top: 2.5px solid #22c55e;
}
.flow-node.warning-node {
--flow-node-border-top: 2.5px solid #f59e0b;
}
.flow-node.error-node {
--flow-node-border-top: 2.5px solid #ef4444;
}
```
Assign the class in your node data:
```php
$this->nodes = [
['id' => '1', 'position' => ['x' => 0, 'y' => 0], 'data' => ['label' => 'Start'], 'class' => 'success-node'],
['id' => '2', 'position' => ['x' => 200, 'y' => 0], 'data' => ['label' => 'Review'], 'class' => 'warning-node'],
['id' => '3', 'position' => ['x' => 400, 'y' => 0], 'data' => ['label' => 'Failed'], 'class' => 'error-node'],
];
```
## Related
- [CSS Variables](css-variables.md) -- full variable reference
- [Dark Mode](dark-mode.md) -- class-based dark mode
- [Configuration](../configuration.md) -- `wireflow.php` config
# Dark Mode
WireFlow uses class-based dark mode, the same convention as Tailwind CSS v4 and Flux UI.
## Class-based
Place `.dark` on an ancestor element or on the flow container itself. The default and Flux themes both include `.dark` overrides for all CSS variables.
```blade
{{-- .dark on (typical Flux UI / Tailwind convention) --}}
```
```blade
{{-- .dark directly on the container via class prop --}}
```
## `colorMode` config
For self-managed color mode without a framework, pass `colorMode` via the `:config` prop:
```blade
```
| Value | Behavior |
|-------|----------|
| `'light'` | Removes `.dark` from the container |
| `'dark'` | Adds `.dark` to the container |
| `'system'` | Watches `prefers-color-scheme` via `matchMedia`, toggles `.dark` automatically |
| `undefined` (default) | No color mode management -- inherit from ancestor |
The resolved mode is available as a reactive getter: `$flow.colorMode` returns `'light'` or `'dark'`.
## System preference tracking
When `colorMode` is set to `'system'`, AlpineFlow registers a `matchMedia` listener for `(prefers-color-scheme: dark)` and toggles the `.dark` class on the container in real time. If your framework already handles this on `` or ``, leave `colorMode` undefined and let the ancestor class cascade.
## Runtime toggle
Toggle dark mode from a Blade button using Alpine:
```blade
{{-- Toggle button inside a panel --}}
```
Or toggle directly via the class:
```blade
```
## Background patterns
Pattern colors auto-adjust when using the default or Flux theme. The theme files set appropriate `--flow-bg-pattern-color` values for both light (subtle gray) and dark (subtle white) modes.
To customize dark mode pattern colors explicitly:
```css
.flow-container.dark {
--flow-bg-color: #0f172a;
--flow-bg-pattern-color: rgba(255, 255, 255, 0.06);
}
```
## Related
- [Themes](themes.md) -- built-in themes and custom theming
- [CSS Variables](css-variables.md) -- full variable reference
# CSS Variables
Every visual property in WireFlow reads from a `--flow-*` CSS custom property declared on `.flow-container`. The structural layer provides fallback defaults; theme layers override them.
Override any variable on `.flow-container` or inline via `style`:
```css
.flow-container {
--flow-bg-color: #0d1117;
--flow-node-bg: #161b22;
--flow-edge-stroke: #58a6ff;
}
```
```blade
```
## Quick brand override
To match your brand, override these accent-related variables. Everything else derives from the neutral scale and stays visually consistent.
```css
.flow-container {
/* Interactive states (selection, hover, focus) */
--flow-node-hover-border-color: #2563eb;
--flow-node-selected-border-color: #2563eb;
--flow-node-selected-shadow: 0 0 0 1px #2563eb;
--flow-node-focus-outline: 2px solid #2563eb;
/* Handles */
--flow-handle-hover-bg: #2563eb;
--flow-handle-active-bg: #2563eb;
/* Edges */
--flow-edge-stroke-selected: #2563eb;
--flow-edge-dot-fill: #2563eb;
--flow-edge-focus-stroke: #2563eb;
/* Selection box & lasso */
--flow-selection-bg: rgba(37, 99, 235, 0.06);
--flow-selection-border: 1px solid rgba(37, 99, 235, 0.3);
--flow-lasso-stroke: rgba(37, 99, 235, 0.5);
/* Resize handles */
--flow-resizer-border: 1px solid #2563eb;
--flow-resizer-hover-bg: #2563eb;
/* Accent stripe (top border on nodes) */
--flow-node-border-top: 2.5px solid #93c5fd;
}
```
Replace `#2563eb` / `#93c5fd` with your brand color and its lighter variant.
## Runtime theme switching
CSS variables are live -- changing them at runtime immediately updates the flow.
```blade
```
## Full variable reference
### Container & Canvas
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-container-height` | `400px` | Container height |
| `--flow-bg-color` | `transparent` | Canvas background color |
| `--flow-bg-pattern-color` | `rgba(0,0,0,0.15)` | Dot/line pattern color |
| `--flow-bg-pattern-gap` | `20` | Pattern spacing |
### Nodes
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-node-min-width` | `0` | Minimum node width |
| `--flow-node-bg` | `#fff` | Node background |
| `--flow-node-color` | `inherit` | Node text color |
| `--flow-node-border` | `1px solid #1a192b` | Node border |
| `--flow-node-border-top` | `var(--flow-node-border)` | Top accent stripe |
| `--flow-node-border-radius` | `3px` | Node corner radius |
| `--flow-node-padding` | `5px` | Node padding |
| `--flow-node-shadow` | `none` | Node box shadow |
| `--flow-node-font-size` | `14px` | Node font size |
| `--flow-node-transition` | `none` | Node CSS transitions |
| `--flow-node-hover-border-color` | `transparent` | Hover border color |
| `--flow-node-selected-border-color` | `#555` | Selected border color |
| `--flow-node-selected-shadow` | `0 0 0 0.5px #555` | Selected shadow ring |
| `--flow-node-focus-outline` | `2px solid Highlight` | Keyboard focus outline |
| `--flow-node-focus-outline-offset` | `2px` | Focus outline offset |
| `--flow-node-locked-opacity` | `1` | Locked node opacity |
| `--flow-node-locked-border-style` | `solid` | Locked node border style |
### Handles
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-handle-size` | `10px` | Handle diameter |
| `--flow-handle-bg` | `#333` | Handle background |
| `--flow-handle-border` | `1px solid #fff` | Handle border |
| `--flow-handle-hover-bg` | (theme) | Handle hover background |
| `--flow-handle-active-bg` | `transparent` | Handle active/dragging background |
| `--flow-handle-invalid-bg` | `#ef4444` | Invalid connection target |
### Edges
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-edge-stroke` | `#b1b1b7` | Edge stroke color |
| `--flow-edge-stroke-width` | `1.5` | Edge stroke width |
| `--flow-edge-stroke-selected` | `#555` | Selected edge stroke |
| `--flow-edge-stroke-width-selected` | `2.5` | Selected edge width |
| `--flow-edge-transition` | `stroke 0.3s ease` | Edge CSS transition |
| `--flow-edge-marker-color` | (theme) | Arrow marker color |
| `--flow-edge-animated-dasharray` | `6 3` | Animated dash pattern |
| `--flow-edge-animated-duration` | `0.5s` | Dash animation duration |
| `--flow-edge-pulse-duration` | `2s` | Pulse animation cycle |
| `--flow-edge-pulse-min-opacity` | `0.3` | Pulse minimum opacity |
| `--flow-edge-dot-size` | `4` | Dot animation circle size |
| `--flow-edge-dot-fill` | `currentColor` | Dot fill color |
| `--flow-edge-dot-duration` | `2s` | Dot travel duration |
| `--flow-edge-focus-stroke` | `currentColor` | Keyboard focus stroke |
| `--flow-edge-focus-stroke-width` | `2.5` | Focus stroke width |
| `--flow-edge-reconnecting-opacity` | `0.25` | Reconnecting edge opacity |
### Edge Labels
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-edge-label-bg` | `#fff` | Label background |
| `--flow-edge-label-border` | `none` | Label border |
| `--flow-edge-label-border-radius` | `0` | Label corner radius |
| `--flow-edge-label-padding` | `2px 8px` | Label padding |
| `--flow-edge-label-font-size` | `11px` | Label font size |
| `--flow-edge-label-color` | `inherit` | Label text color |
### Connection Line
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-connectionline-stroke` | `var(--flow-edge-stroke)` | In-progress connection color |
| `--flow-connectionline-stroke-width` | `var(--flow-edge-stroke-width)` | In-progress connection width |
| `--flow-connection-line-invalid` | `#ef4444` | Invalid connection color |
### Selection
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-selection-bg` | `rgba(0,89,220,0.08)` | Selection box fill |
| `--flow-selection-border` | `1px dotted rgba(0,89,220,0.8)` | Selection box border |
| `--flow-selection-border-radius` | `0` | Selection box radius |
| `--flow-lasso-stroke` | (theme) | Lasso outline stroke |
| `--flow-selection-full-bg` | (theme) | Full-containment mode fill |
| `--flow-selection-full-border-color` | (theme) | Full-containment border |
| `--flow-lasso-stroke-full` | (theme) | Full-containment lasso stroke |
### Controls
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-controls-gap` | `1px` | Gap between control buttons |
| `--flow-controls-btn-width` | `28px` | Button width |
| `--flow-controls-btn-height` | `28px` | Button height |
| `--flow-controls-btn-bg` | `#fefefe` | Button background |
| `--flow-controls-btn-border` | `1px solid #eee` | Button border |
| `--flow-controls-btn-color` | `inherit` | Button icon/text color |
| `--flow-controls-btn-border-radius` | `0` | Button corner radius |
| `--flow-controls-btn-hover-bg` | `#f4f4f4` | Button hover background |
### Minimap
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-minimap-border` | `1px solid #eee` | Minimap border |
| `--flow-minimap-border-radius` | `0` | Minimap corner radius |
| `--flow-minimap-bg` | `#fff` | Minimap background |
| `--flow-minimap-node-color` | `#e2e2e2` | Node representation color |
| `--flow-minimap-mask-color` | `rgba(240,240,240,0.6)` | Viewport mask overlay |
### Node Toolbar
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-node-toolbar-padding` | `4px 6px` | Toolbar padding |
| `--flow-node-toolbar-bg` | `transparent` | Toolbar background |
| `--flow-node-toolbar-border` | `none` | Toolbar border |
| `--flow-node-toolbar-border-radius` | `0` | Toolbar corner radius |
| `--flow-node-toolbar-btn-bg` | `transparent` | Button background |
| `--flow-node-toolbar-btn-border` | `none` | Button border |
| `--flow-node-toolbar-btn-color` | `inherit` | Button text color |
| `--flow-node-toolbar-btn-padding` | `4px 8px` | Button padding |
| `--flow-node-toolbar-btn-border-radius` | `0` | Button corner radius |
| `--flow-node-toolbar-btn-font-size` | `12px` | Button font size |
| `--flow-node-toolbar-btn-hover-bg` | `transparent` | Button hover background |
### Drag Handle
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-drag-handle-bg` | `transparent` | Handle background |
| `--flow-drag-handle-border-bottom` | `none` | Bottom separator |
| `--flow-drag-handle-padding` | `6px 12px` | Handle padding |
| `--flow-drag-handle-border-radius` | `0` | Handle corner radius |
| `--flow-drag-handle-font-size` | `12px` | Handle font size |
| `--flow-drag-handle-font-weight` | `600` | Handle font weight |
| `--flow-drag-handle-color` | `inherit` | Handle text color |
### Resize Handles
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-resizer-bg` | `transparent` | Resizer background |
| `--flow-resizer-border` | `none` | Resizer border |
| `--flow-resizer-hover-bg` | `transparent` | Resizer hover background |
### Panel
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-panel-bg` | `transparent` | Panel background |
| `--flow-panel-border` | `none` | Panel border |
| `--flow-panel-border-radius` | `0` | Panel corner radius |
| `--flow-panel-min-width` | `100px` | Panel minimum width |
| `--flow-panel-min-height` | `60px` | Panel minimum height |
| `--flow-panel-resize-bg` | `transparent` | Resize grip background |
| `--flow-panel-resize-border-radius` | `0` | Resize grip radius |
| `--flow-panel-resize-hover-bg` | `transparent` | Resize grip hover |
### Group Nodes
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-node-group-bg` | `transparent` | Group background |
| `--flow-node-group-border` | `none` | Group border |
| `--flow-node-group-border-radius` | `0` | Group corner radius |
| `--flow-node-group-shadow` | `none` | Group shadow |
| `--flow-node-group-font-size` | `inherit` | Group label font size |
| `--flow-node-group-text-transform` | `none` | Group label text transform |
| `--flow-node-group-letter-spacing` | `normal` | Group label letter spacing |
| `--flow-node-group-padding` | `0` | Group label padding |
| `--flow-node-group-hover-border-color` | `transparent` | Group hover border |
### Validation
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-node-invalid-border` | `1.5px dashed #ef4444` | Invalid child border |
| `--flow-node-invalid-shadow` | `0 0 0 2px rgba(239,68,68,0.15)` | Invalid child shadow |
| `--flow-node-drop-target-border` | `1.5px dashed #3b82f6` | Drop target border |
| `--flow-node-drop-target-shadow` | `0 0 0 2px rgba(59,130,246,0.2)` | Drop target shadow |
### Rotate Handle
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-rotate-handle-size` | `14px` | Handle diameter |
| `--flow-rotate-handle-offset` | `24px` | Distance above node |
| `--flow-rotate-handle-bg` | (theme) | Handle fill |
| `--flow-rotate-handle-border` | (theme) | Handle border |
| `--flow-rotate-handle-line-color` | (theme) | Stem line color |
### Whiteboard Tools
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-tool-stroke-color` | `#52525b` | Default drawing stroke |
| `--flow-tool-highlighter-color` | `#fbbf24` | Highlighter color |
### Layout Animation
| Variable | Structural Default | Description |
|---|---|---|
| `--flow-layout-animation-duration` | `0.3s` | Auto-layout transition |
---
## Z-Index Layers
WireFlow uses a structured z-index system for element stacking. These values are hardcoded in structural CSS and are not themeable.
| z-index | Element | Class | Purpose |
|---------|---------|-------|---------|
| 1000 | Eraser trail | `.flow-eraser-svg` | Whiteboard eraser overlay |
| 60 | DevTools | `.flow-devtools` | Debug overlay |
| 55 | Touch selection indicator | `.flow-touch-selection-mode-indicator` | Touch mode badge |
| 50 | Loading overlay | `.flow-loading-overlay` | Blocks interaction during load |
| 20 | Node toolbar | `.flow-node-toolbar` | Floating toolbar above nodes |
| 20 | Edge toolbar | `.flow-edge-toolbar` | Floating toolbar on edges |
| 15 | Rotate handle | `.flow-rotate-handle` | Above resize handles |
| 12 | Controls panel | `.flow-controls` | Zoom/fit-view buttons |
| 12 | Minimap | `.flow-minimap` | Overview panel |
| 11 | Panel | `.flow-panel` | Draggable overlay panels |
| 10 | Handles | `.flow-handle` | Connection handles on nodes |
| 7 | Selection box | `.flow-selection-box` | Drag-to-select rectangle |
| 7 | Lasso | `.flow-lasso-svg` | Freeform selection |
| 5 | Resize handles | `.flow-resizer-handle` | Corner/edge resize grips |
| 2 | Nodes | `.flow-node` | Regular nodes |
| 1 | Edges | `.flow-edges` | Edge SVG layer |
| 1 | Edge labels | `.flow-edge-label` | HTML label overlays |
| 0 | Group nodes | `.flow-node-group` | Groups render below child nodes |
| -1 | Touch hit areas | `.flow-handle::before` | Expanded touch targets |
To override stacking for a specific use case:
```css
.flow-panel {
z-index: 15; /* put panels above controls */
}
```
## Override examples with Blade
Override at the component level using inline styles:
```blade
{{-- Custom dark canvas with green accents --}}
```
```blade
{{-- Compact nodes with no accent stripe --}}
```
## Related
- [Themes](themes.md) -- built-in themes and custom theming
- [Dark Mode](dark-mode.md) -- class-based dark mode
# Whiteboard
The Whiteboard addon adds freehand drawing, highlighting, shape drawing, text placement, and erasing capabilities to your WireFlow canvas.
## Installation
Install the AlpineFlow npm package (if you haven't already) and register the whiteboard plugin:
```bash
npm install @getartisanflow/alpineflow
```
No additional peer dependencies are required.
In your `resources/js/app.js`:
```js
// Core from WireFlow vendor bundle
import AlpineFlow from '../../vendor/getartisanflow/wireflow/dist/alpineflow.bundle.esm.js';
// Whiteboard addon from npm
import AlpineFlowWhiteboard from '@getartisanflow/alpineflow/whiteboard';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
window.Alpine.plugin(AlpineFlowWhiteboard);
});
```
Rebuild after adding the import:
```bash
npm run build
```
## Directives
All whiteboard directives are placed directly on the `` component as attributes. Each directive's expression is a boolean that controls whether the tool is currently active.
| Directive | Description |
|---|---|
| `x-flow-freehand` | Freehand pen drawing with pressure-sensitive strokes |
| `x-flow-highlighter` | Semi-transparent highlighter strokes |
| `x-flow-arrow-draw` | Click-and-drag to draw arrow annotations |
| `x-flow-circle-draw` | Click-and-drag to draw circle annotations |
| `x-flow-rectangle-draw` | Click-and-drag to draw rectangle annotations |
| `x-flow-text-tool` | Click to place editable text annotations |
| `x-flow-eraser` | Drag to paint over elements, release to delete |
## Blade setup
Since `` creates its own `x-data="flowCanvas({...})"`, you cannot define `tool` and `toolSettings` in a parent scope -- directives on the `` element evaluate in the flowCanvas scope, not the parent. Use `x-init` with `Object.assign($data, ...)` to inject properties into the flowCanvas scope:
```blade
```
> **Important:** `toolSettings` must be a top-level Alpine scope property injected via `Object.assign($data, ...)`. Do NOT pass it inside the `:config` prop.
## Tool settings
Configure drawing properties via `toolSettings` -- an object with `strokeColor`, `strokeWidth`, and `opacity`. The directives read this from the Alpine scope.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `strokeColor` | `string` | `'#334155'` | Stroke/fill color for all tools |
| `strokeWidth` | `number` | `2` | Stroke width for shapes and lines |
| `opacity` | `number` | `1` | Opacity for all drawing output |
## Event listeners
Drawing tool events (`flow-freehand-end`, `flow-rectangle-draw`, etc.) are dispatched on the `.flow-container` element. In WireFlow, you cannot use `@@event` attributes on `` (Livewire crashes on custom event names with hyphens). Instead, attach listeners in `x-init` using `$el.addEventListener()`:
```blade
```
### Events reference
| Event | Emitted by | Detail |
|---|---|---|
| `flow-freehand-end` | `x-flow-freehand` | `{ pathData, strokeColor, opacity }` |
| `flow-highlight-end` | `x-flow-highlighter` | `{ pathData, strokeColor, opacity }` |
| `flow-rectangle-draw` | `x-flow-rectangle-draw` | `{ bounds: { x, y, width, height }, strokeColor, strokeWidth, opacity }` |
| `flow-arrow-draw` | `x-flow-arrow-draw` | `{ start: { x, y }, end: { x, y }, strokeColor, strokeWidth, opacity }` |
| `flow-circle-draw` | `x-flow-circle-draw` | `{ cx, cy, rx, ry, strokeColor, strokeWidth, opacity }` |
| `flow-text-draw` | `x-flow-text-tool` | `{ position: { x, y }, strokeColor, fontSize, opacity }` |
## Annotation node templates
Annotations are stored as regular nodes with `class: 'flow-node-annotation'` and a `data.annotation` field that identifies the type. Your node template must render each annotation type.
All 6 annotation types need templates in addition to your regular node template:
```blade
{{-- Freehand / Highlighter: filled SVG path --}}
{{-- Rectangle: dashed border div --}}
{{-- Arrow: SVG line with arrowhead marker --}}
{{-- Circle: SVG ellipse --}}
{{-- Text: contenteditable div --}}
{{-- Regular node (non-annotation) --}}
```
Key points:
- Annotation SVGs use `position:absolute;width:1px;height:1px;overflow:visible` to render at flow coordinates without affecting node sizing.
- `draggable: false` and `selectable: false` prevent annotations from being interacted with as nodes.
- The `.flow-node-annotation` CSS class strips default node styling (background, border, shadow).
- The eraser tool does not need an event listener -- it deletes nodes directly.
## Toolbar
Build a tool picker using buttons or a panel. The `tool` property was injected via `Object.assign($data, ...)`, so it is reactive in the flowCanvas scope:
```blade
```
### Color swatches
```blade
@foreach(['#334155', '#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6'] as $color)
@endforeach
```
### Stroke width
```blade
Width:
```
## Eraser behavior
The eraser uses a drag-to-paint interaction model. Drag over elements to mark them for deletion (a red trail follows the cursor), then release to remove them. It uses segment-rect intersection to determine which nodes fall under the eraser path. No event listener is needed -- the eraser deletes nodes directly.
## Annotations as nodes
All annotations are stored as regular nodes via `addNodes()`. This means they automatically integrate with:
- **Undo/redo** -- annotation creation and deletion are part of the history stack.
- **Collaboration** -- annotations sync across users via Yjs shared types (when used with the [Collaboration](collab.md) addon).
- **Server sync** -- annotations appear in `$nodes` on the server like any other node.
## Complete working example
A full Livewire component with whiteboard tools. This includes all 7 directives, all 6 annotation templates, a toolbar, color picker, and stroke width control.
### Livewire component
```php
'start', 'position' => ['x' => 100, 'y' => 150], 'data' => ['label' => 'Start']],
['id' => 'end', 'position' => ['x' => 400, 'y' => 150], 'data' => ['label' => 'End']],
];
public array $edges = [
['id' => 'e1', 'source' => 'start', 'target' => 'end', 'markerEnd' => 'arrowclosed'],
];
public function render(): \Illuminate\View\View
{
return view('livewire.whiteboard-demo');
}
}
```
### Blade template
```blade
{{-- resources/views/livewire/whiteboard-demo.blade.php --}}
@foreach(['#334155', '#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6'] as $color)
@endforeach
{{-- Stroke width --}}
Width:
```
::demo
```html
```
::enddemo
## CSS variables
| Variable | Default | Description |
|---|---|---|
| `--flow-tool-stroke-color` | `#52525b` | Default drawing stroke |
| `--flow-tool-highlighter-color` | `#fbbf24` | Highlighter color |
## Related
- [Installation](../getting-started/installation.md#optional-addons) -- addon setup
- [Collaboration](collab.md) -- annotations sync with Yjs
# Layout Engines
WireFlow supports four auto-layout algorithms via addon plugins. Each runs on the client to reposition nodes, with optional animated transitions.
## Installation
Install the AlpineFlow npm package (core from npm gives you access to addon sub-path imports -- WireFlow's vendor bundle provides the core runtime):
```bash
npm install @getartisanflow/alpineflow
```
Then install only the peer dependencies you need:
```bash
npm install @dagrejs/dagre # for dagre layout
npm install d3-force # for force-directed layout
npm install d3-hierarchy # for tree/cluster layout
npm install elkjs # for ELK layout engine
```
### app.js import pattern
Core comes from the WireFlow vendor bundle. Addons come from npm. They share a global registry.
```js
// resources/js/app.js
// Core from WireFlow vendor bundle
import AlpineFlow from '../../vendor/getartisanflow/wireflow/dist/alpineflow.bundle.esm.js';
// Layout addons from npm (only import what you installed)
import AlpineFlowDagre from '@getartisanflow/alpineflow/dagre';
import AlpineFlowForce from '@getartisanflow/alpineflow/force';
import AlpineFlowHierarchy from '@getartisanflow/alpineflow/hierarchy';
import AlpineFlowElk from '@getartisanflow/alpineflow/elk';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
window.Alpine.plugin(AlpineFlowDagre);
window.Alpine.plugin(AlpineFlowForce);
window.Alpine.plugin(AlpineFlowHierarchy);
window.Alpine.plugin(AlpineFlowElk);
});
```
Rebuild after adding imports:
```bash
npm run build
```
---
## Dagre Layout
Hierarchical layout using the [dagre](https://github.com/dagrejs/dagre) algorithm. Best for directed acyclic graphs, org charts, and tree-like structures.
### Client-side usage
```blade
```
### Options
| Option | Type | Default | Description |
|---|---|---|---|
| `direction` | `string` | `'TB'` | `'TB'` (top-bottom), `'LR'` (left-right), `'BT'` (bottom-top), `'RL'` (right-left) |
| `nodesep` | `number` | `50` | Horizontal spacing between nodes in the same rank |
| `ranksep` | `number` | `50` | Spacing between ranks (layers) |
| `adjustHandles` | `boolean` | `true` | Set handle positions to match layout direction |
| `fitView` | `boolean` | `true` | Fit viewport after layout |
| `duration` | `number` | `0` | Animation duration in ms (0 for instant) |
---
## Force Layout
Physics-based layout using [d3-force](https://github.com/d3/d3-force). Connected nodes attract, unconnected nodes repel. Produces organic, natural-looking layouts.
### Client-side usage
```blade
```
### Options
| Option | Type | Default | Description |
|---|---|---|---|
| `strength` | `number` | `0.1` | Link force strength -- how strongly connected nodes pull toward each other |
| `distance` | `number` | `100` | Ideal link distance between connected nodes |
| `charge` | `number` | `-300` | Charge force (negative = repel, positive = attract) |
| `iterations` | `number` | `300` | Number of simulation ticks to run |
| `center` | `{ x, y }` | `undefined` | Center point for the centering force |
| `fitView` | `boolean` | `true` | Fit viewport after layout |
| `duration` | `number` | `0` | Animation duration in ms (0 for instant) |
---
## Tree Layout
Tree and cluster layouts using [d3-hierarchy](https://github.com/d3/d3-hierarchy). Ideal for hierarchical data with a single root -- file systems, org charts, decision trees.
### Client-side usage
```blade
```
### Options
| Option | Type | Default | Description |
|---|---|---|---|
| `layoutType` | `string` | `'tree'` | `'tree'` (tidy tree) or `'cluster'` (dendrogram -- all leaves at same depth) |
| `direction` | `string` | `'TB'` | `'TB'`, `'LR'`, `'BT'`, `'RL'` |
| `nodeWidth` | `number` | `150` | Horizontal spacing per node |
| `nodeHeight` | `number` | `100` | Vertical spacing per node |
| `adjustHandles` | `boolean` | `true` | Set handle positions to match layout direction |
| `fitView` | `boolean` | `true` | Fit viewport after layout |
| `duration` | `number` | `0` | Animation duration in ms (0 for instant) |
### Tree vs. Cluster
- **Tree** (`'tree'`) -- a tidy tree layout that minimizes width while keeping nodes at their natural depth.
- **Cluster** (`'cluster'`) -- a dendrogram layout that places all leaf nodes at the same depth, useful for comparing terminal nodes.
---
## ELK Layout
Advanced layout algorithms from the [Eclipse Layout Kernel](https://www.eclipse.org/elk/) via [elkjs](https://github.com/kieler/elkjs). ELK offers the most comprehensive set of layout strategies.
### Client-side usage
```blade
```
### Options
| Option | Type | Default | Description |
|---|---|---|---|
| `algorithm` | `string` | `'layered'` | Layout algorithm (see algorithms table) |
| `direction` | `string` | `'DOWN'` | `'DOWN'`, `'RIGHT'`, `'UP'`, `'LEFT'` |
| `nodeSpacing` | `number` | `50` | Minimum spacing between nodes |
| `layerSpacing` | `number` | `50` | Minimum spacing between layers |
| `adjustHandles` | `boolean` | `true` | Set handle positions to match layout direction |
| `fitView` | `boolean` | `true` | Fit viewport after layout |
| `duration` | `number` | `0` | Animation duration in ms (0 for instant) |
### Algorithms
| Algorithm | Description |
|---|---|
| `'layered'` | Layer-based approach for directed graphs. Clean hierarchical layouts with minimal edge crossings. |
| `'stress'` | Stress-minimization. Graph-theoretic distances match geometric distances. |
| `'mrtree'` | Optimized for tree structures. |
| `'radial'` | Concentric circles radiating from a root node. |
| `'force'` | Force-directed layout (ELK's implementation). |
| `'box'` | Packs disconnected components into a compact rectangle. |
| `'random'` | Random placement. Starting point for other algorithms. |
---
## Server-side layout
Use the `WithWireFlow` trait to trigger layout from the server. The `flowLayout()` method dispatches the layout command to the client:
```php
use ArtisanFlow\WireFlow\Concerns\WithWireFlow;
class FlowEditor extends Component
{
use WithWireFlow;
public function applyDagreLayout(): void
{
$this->flowLayout([
'algorithm' => 'dagre',
'direction' => 'TB',
'duration' => 300,
]);
}
public function applyForceLayout(): void
{
$this->flowLayout([
'algorithm' => 'force',
'charge' => -500,
'duration' => 500,
]);
}
public function applyTreeLayout(): void
{
$this->flowLayout([
'algorithm' => 'tree',
'direction' => 'LR',
'duration' => 300,
]);
}
public function applyElkLayout(): void
{
$this->flowLayout([
'algorithm' => 'elk',
'elkAlgorithm' => 'layered',
'direction' => 'DOWN',
'duration' => 300,
]);
}
}
```
```blade
```
---
## Auto-layout config
Configure automatic layout via the `:config` prop. When enabled, the graph re-layouts on structural changes (node/edge additions and removals):
```blade
```
When `autoLayout` is set, adding or removing nodes/edges triggers an automatic re-layout with the configured algorithm and options.
::demo
```toolbar
```
```html
```
::enddemo
## Layout animation CSS
The transition duration for animated layouts can be customized via CSS:
```css
.flow-container {
--flow-layout-animation-duration: 0.5s;
}
```
## Related
- [Installation](../getting-started/installation.md#optional-addons) -- addon setup
- [Server Commands](../server/trait.md#layout--state) -- `flowLayout()` trait method
- [Animation Basics](../animation/basics.md) -- animating node positions
# Collaboration
The Collaboration addon enables real-time multi-user editing of WireFlow diagrams using [Yjs](https://yjs.dev/) conflict-free replicated data types (CRDTs). All node and edge changes sync automatically across connected clients.
## Installation
Install the required peer dependencies and the AlpineFlow npm package:
```bash
npm install @getartisanflow/alpineflow yjs y-websocket y-protocols
```
Register the plugin in your `resources/js/app.js`:
```js
// Core from WireFlow vendor bundle
import AlpineFlow from '../../vendor/getartisanflow/wireflow/dist/alpineflow.bundle.esm.js';
// Collaboration addon from npm
import AlpineFlowCollab from '@getartisanflow/alpineflow/collab';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
window.Alpine.plugin(AlpineFlowCollab);
});
```
Rebuild after adding the import:
```bash
npm run build
```
## Configuration
Pass a `collab` object via the `:config` prop:
```blade
```
### Config options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `provider` | `string` | — | Provider type: `'websocket'` or `'reverb'` |
| `url` | `string` | — | WebSocket server URL |
| `room` | `string` | — | Room/document name for sync |
| `user` | `object` | — | `{ name: string, color: string }` |
| `cursors` | `boolean` | `true` | Show remote user cursors |
| `selections` | `boolean` | `true` | Show remote user selections |
| `throttle` | `number` | `20` | Cursor broadcast throttle in ms |
## Providers
Three provider types are available:
### WebSocket provider
Standard y-websocket connection. Requires a running [y-websocket server](https://github.com/yjs/y-websocket).
```blade
```
### Laravel Reverb provider
Connects via a running [Laravel Reverb](https://reverb.laravel.com) server.
```blade
```
### InMemoryProvider
For testing and demos, `InMemoryProvider` requires no server. Use `linkProviders()` to synchronize two instances client-side. Import these from the collab addon:
```js
import { InMemoryProvider, linkProviders } from '@getartisanflow/alpineflow/collab';
```
This is used internally for documentation demos. See the [complete example](#complete-example) below for a two-canvas demo setup.
## Remote cursors
Use the `x-flow-cursors` directive on `` to render remote user cursors:
```blade
```
Each remote cursor renders as:
- An SVG arrow pointer filled with the user's `color`
- A name label badge positioned beside the arrow
- Smooth CSS transitions (100ms ease-out) as the cursor moves
### Cursor CSS customization
The cursor elements have the class `.flow-collab-cursor` with children `.flow-collab-cursor-arrow` (SVG path) and `.flow-collab-cursor-label` (name badge). Override these to customize:
```css
/* Larger name labels */
.flow-collab-cursor-label {
font-size: 13px;
padding: 3px 10px;
}
/* Hide name labels entirely */
.flow-collab-cursor-label {
display: none;
}
/* Custom cursor animation */
.flow-collab-cursor {
transition: transform 200ms ease-out;
}
```
## User presence via `$flow.collab`
When collaboration is active, `$flow.collab` exposes reactive presence data:
| Property | Type | Description |
|----------|------|-------------|
| `users` | `CollabUser[]` | All connected users (reactive) |
| `userCount` | `number` | Number of connected users |
| `me` | `CollabUser` | Local user info `{ name, color }` |
| `connected` | `boolean` | Whether the provider is currently connected |
| `status` | `string` | `'connecting'`, `'connected'`, or `'disconnected'` |
### Awareness state shape
Each connected user broadcasts this state:
```js
{
user: { name: 'Alice', color: '#3b82f6' },
cursor: { x: 150, y: 200 }, // pointer position in flow coords, or null
selectedNodes: ['node-1', 'node-3'], // currently selected node IDs
viewport: { x: 0, y: 0, zoom: 1 }, // user's viewport
}
```
### User presence list example
```blade
```
### Connection status indicator example
```blade
```
> **Live demos:** See the [AlpineFlow collab example](/examples/collaboration) (simulated with InMemoryProvider) and the [WireFlow collab example](/examples/wireflow/collab) (real multi-user via Reverb).
## Sync behavior
All node and edge changes automatically sync across connected clients via Yjs shared types:
- Adding, removing, and updating nodes
- Adding, removing, and updating edges
- Node position changes (drag)
- Annotation drawing (when used with the [Whiteboard](whiteboard.md) addon)
- Undo/redo operations
## Production gotchas
These apply to both AlpineFlow and WireFlow when using ReverbProvider in production. See the [AlpineFlow collab docs](/docs/alpineflow/addons/collab) for detailed coverage.
### Use stateUrl for initial state
Without `stateUrl`, clients that load simultaneously create independent Yjs Y.Map instances for the same nodes. The CRDT resolves duplicates, but modifications to the "losing" Y.Maps are invisible to other clients — causing one-directional sync.
**Always provide a `stateUrl`** endpoint that serves a pre-encoded Yjs state. All clients start from the same CRDT baseline. The ReverbProvider also saves state back to this URL every 5 seconds (debounced) so late joiners get the current graph.
```blade
:config="[
'collab' => [
'provider' => WireFlow::js('new window.ReverbProvider({
roomId: \'' . $roomId . '\',
channel: \'collab-room.' . $roomId . '\',
user: { ... },
stateUrl: \'/api/collab/{roomId}/state\',
})'),
],
]"
```
### Reverb: accept_client_events_from
Reverb's `accept_client_events_from` setting defaults to `'members'` (presence channels only). For private channel collab, set it to `'all'` in `config/reverb.php`. Only `'all'` and `'members'` are valid — any other value silently blocks all whispers. **Restart Reverb after changing.**
### Reverb: message size limits
Yjs updates can exceed Reverb's default 10KB limit. Set `max_message_size` and `max_request_size` to at least 500KB in `config/reverb.php`.
### Cursor positioning in WireFlow
The `x-flow-cursors` element renders in WireFlow's default slot, which is **outside** the viewport div. Remote cursors use flow-space coordinates and need the viewport's CSS transform to position correctly. Move the element into the viewport on mount:
```html
```
### Anonymous broadcasting auth
Private channels require authentication. For anonymous collab (no user model), register a custom `/broadcasting/auth` route with manual Pusher HMAC signing that uses session-based identity instead of Laravel's auth middleware.
## Related
- [AlpineFlow Collab Docs](/docs/alpineflow/addons/collab) — full collab reference with all config options
- [Live Collaboration Example](/examples/wireflow/collab) — production Reverb example with room management
- [Installation](../getting-started/installation.md#optional-addons) — addon setup
- [Whiteboard](whiteboard.md) — annotations sync via collab
# Schema Addon
The WireFlow schema addon provides Blade components, a server-side trait, and Laravel validator rules for AlpineFlow's [schema addon](https://artisanflow.dev/alpineflow/docs/addons/schema). Use it to build database-schema designers, ER diagrams, and resource graphs in Livewire.
## Installation
The schema addon ships in the same WireFlow package — no separate install required. Run the install command once and it's available:
```bash
php artisan wireflow:install
```
For consumers who load AlpineFlow from npm rather than the bundled WireFlow dist, register the schema addon plugin alongside the core:
```js
import AlpineFlow from '@getartisanflow/alpineflow';
import AlpineFlowSchema from '@getartisanflow/alpineflow/schema';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
window.Alpine.plugin(AlpineFlowSchema);
});
```
The bundled WireFlow dist already includes the schema addon, so the npm path is only needed when you're managing AlpineFlow yourself.
## Components
| Component | Purpose |
| --- | --- |
| [``](../components/schema-designer.md) | Opinionated full drop-in: canvas, inspectors, save controls. |
| [``](../components/schema-node.md) | Directive wrapper for schema-style nodes with optional SSR fallback. |
| [``](../components/schema-field.md) | Composable row primitive — use to build custom field rows. |
| [``](../components/schema-inspector.md) | Three-scope inspector — node level. |
| [``](../components/schema-inspector.md) | Three-scope inspector — row level. |
| [``](../components/schema-inspector.md) | Three-scope inspector — edge level. |
## Server-side trait
[`WithSchemaDesigner`](../traits/with-schema-designer.md) — server-side CRUD over fields, edge cascade on field rename/remove, and event hooks for downstream persistence.
## Validator rules
| Rule | Purpose |
| --- | --- |
| `SchemaFieldName` | Validates field-name shape (no spaces, leading-letter, …). |
| `SchemaEdgeShape` | Validates edge connection shape (source/target match, sourceHandle present). |
## Wire bridge
The schema addon adds an asynchronous validation gate around drag-to-connect: dispatch `@connect-validate` from the canvas, resolve from the server, and the connection only commits when the server says yes.
See [Server events](../server/events.md) for the full event reference.
## Quick start
```php
'user', 'data' => ['label' => 'User', 'fields' => [['name' => 'id', 'type' => 'uuid', 'key' => 'primary']]]],
];
public array $edges = [];
public function render()
{
return view('livewire.schema-editor');
}
}
```
```blade
```
That's the full surface — node rendering, drag-to-connect, inspectors, and server sync are all wired.
# Workflow Addon
The WireFlow workflow addon exposes the AlpineFlow [workflow addon](https://artisanflow.dev/alpineflow/docs/addons/workflow) to Livewire components. Use it to drive workflow runs from the server, sync run-state to nodes, and listen to execution events.
## Installation
The workflow addon is opt-in — register it on the JS side alongside AlpineFlow:
```js
import AlpineFlow from '@getartisanflow/alpineflow';
import AlpineFlowWorkflow from '@getartisanflow/alpineflow/workflow';
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
window.Alpine.plugin(AlpineFlowWorkflow);
});
```
If you're loading AlpineFlow from the bundled WireFlow dist, the workflow addon entry sits next to the core bundle:
```js
import AlpineFlow from '../../vendor/getartisanflow/wireflow/dist/alpineflow.bundle.esm.js';
import AlpineFlowWorkflow from '../../vendor/getartisanflow/wireflow/dist/alpineflow-workflow.esm.js';
```
Once registered, every `` canvas gains the run/replay surface (`$flow.run()`, `$flow.replayExecution()`, `$flow.executionLog`) and the workflow node templates.
## Components
| Component | Purpose |
| --- | --- |
| [``](../components/flow-wait.md) | Wait-node template — header (icon + label + formatted duration) plus top/bottom handles. |
| [``](../components/flow-condition-node.md) | Condition-node template — header + pretty-printed body + true/false handles + branch-taken decoration. |
| [``](../components/flow-replay-controls.md) | Duck-typed playback toolbar (Play/Pause/Restart/Speed + scrubber/progress). |
| [``](../components/flow-execution-log.md) | Dense reactive event viewer — filterable, click-to-highlight, XSS-safe. |
| [``](../components/flow-run-button.md) | Workflow run trigger that auto-disables during runs. |
| [``](../components/flow-stop-button.md) | Halts an active run; hidden when idle by default. |
| [``](../components/flow-reset-button.md) | Clears node runState and the execution log. |
## Server-side trait methods
These live on `WithWireFlow` — see [Trait reference](../server/trait.md#workflow-addon) for the full method table.
| Method | Description |
| --- | --- |
| `$this->flowRun(string $startId, array $options = [])` | Dispatch a `flow:run` event so the client-side workflow addon executes the workflow. |
| `$this->flowSetNodeState(string\|array $ids, string $state)` | Set `runState` on one or more nodes. |
| `$this->flowResetStates()` | Clear `runState` from all nodes. |
`flowSetNodeState()` valid states: `pending`, `running`, `completed`, `failed`, `skipped`.
## Wire bridge events
The Livewire ↔ AlpineFlow bridge translates these dispatches into client-side calls on the workflow addon:
| Dispatched event | Payload | Effect |
| --- | --- | --- |
| `flow:run` | `{ startId: string, options?: object }` | Calls `$flow.run(startId, handlers, options)` on the canvas. |
| `flow:setNodeState` | `{ ids: string \| string[], state: string }` | Updates `runState` on the matching nodes. |
| `flow:resetStates` | (none) | Clears every node's `runState`. |
## Pre-registering handlers
Workflow handlers (`onEnter`, `onExit`, `pickBranch`, …) are JavaScript callbacks; they can't be serialized from PHP. Pre-register them on the canvas with `x-init` so they're available when `flowRun()` dispatches:
```blade
```
The forthcoming `` primitive reads from `$el.runHandlers` by default — see the upcoming UI-primitives release for the auto-wired flow.
## Validation
The addon ships a pure helper that mirrors `validateSchema()`:
```js
const result = $flow.validateWorkflow();
// → { valid: boolean, issues: WorkflowValidationIssue[] }
```
Issue codes returned:
| Severity | Code | Meaning |
| --- | --- | --- |
| error | `dangling-edge` | Edge source or target node doesn't exist. |
| error | `duplicate-node-id` | Two nodes share an id. |
| error | `missing-condition` | A `flow-condition` node has neither `condition` nor `evaluate`. |
| error | `condition-missing-branch` | A `flow-condition` node is missing its `true` or `false` outgoing edge. |
| error | `unhandled-source-handle` | A `flow-condition` outgoing edge has a `sourceHandle` other than `true` / `false`. |
| error | `wait-missing-duration` | A `flow-wait` node has non-numeric or missing `data.durationMs`. |
| warning | `unreachable-node` | A node has no incoming and no outgoing edges. |
| warning | `cycle` | A directed cycle exists in the graph. |
Use it from a server action via [``](../components/action.md) or wire a `wire:click` to a button that calls `$flow.validateWorkflow()` and posts the result back.
## Quick start
```php
'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['label' => 'Start']],
['id' => 'wait', 'type' => 'flow-wait', 'position' => ['x' => 200, 'y' => 0], 'data' => ['label' => 'Cooldown', 'durationMs' => 2000]],
];
public array $edges = [
['id' => 'e1', 'source' => 'trigger', 'target' => 'wait'],
];
public function startRun(): void
{
$this->flowRun('trigger');
}
public function render()
{
return view('livewire.workflow-editor');
}
}
```
```blade
```
# Upgrading WireFlow to v0.2.1-alpha
This is the single upgrade path from the v0.1.x line. It **resyncs the bundled AlpineFlow engine to v0.2.1-alpha** — a broad correctness-and-performance pass spanning the measurement/layout lifecycle, edge routing, viewport culling, undo/redo history, interaction fixes, and an observable `fitView`. Those shifts live inside the vendored bundle (`dist/alpineflow.bundle.esm.js`), so **WireFlow users receive them the moment they upgrade and republish assets** — you don't opt into them separately.
On the WireFlow side there are a few native things to know about: enum-prop validation now **throws**, the graph-mutating trait methods keep server state in sync, and `` renders `wire:ignore` by default — plus a correction to the record on `flowClear()`. Everything else in the `WithWireFlow` trait and the `` component is unchanged. The particle-emission and bulk-animation trait methods that were staged as a separate (never-tagged) **v0.2.0-alpha** are folded in below under [Animation & particles](#animation--particles-staged-as-v020-alpha).
For the full engine-side rationale and per-entry before/after, see the companion [AlpineFlow v0.2.1-alpha migration guide](https://artisanflow.dev/docs/alpineflow/migration/v0.2.1-alpha) — this guide summarizes what reaches you through the bundle and points there rather than duplicating every entry.
## What WireFlow users get automatically
After upgrading + `php artisan wireflow:install --force` + `npm run build`:
- **Nodes with rich templated content render correctly** — `ResizeObserver` tracks rendered dimensions, eliminating the class of bugs where node heights stayed locked to a stale pre-render measurement and clipped content that grew after Alpine templates populated.
- **Child layouts update reactively** — mutating `node.childLayout.columns` (or `gap`, `padding`, `headerHeight`, `direction`, `stretch`) triggers `layoutChildren` on the parent automatically. Same for `addNodes` with `parentId`.
- **`$flow.batch(fn)` available** in Blade templates — wrap bulk mutations to consolidate many `addNodes` / `addEdges` calls into a single layout pass.
## New FlowNode properties
All optional. You may add any of these to the array you return in `public array $nodes = [...]`:
- `fixedDimensions` — set `true` to force inline height on a leaf node (otherwise content drives height)
- `resizeObserver` — set `false` to exclude a node from the shared observer
- `minDimensions` / `maxDimensions` — bounds applied by the observer before writing `node.dimensions`
```php
public array $nodes = [
[
'id' => 'stats',
'position' => ['x' => 0, 'y' => 0],
'data' => ['label' => 'Stats'],
'fixedDimensions' => true, // opt-in to locked height
'minDimensions' => ['width' => 200], // Partial — height unconstrained
'maxDimensions' => ['width' => 400, 'height' => 300],
],
];
```
## Alpha-breaking behavior changes (inherited from AlpineFlow)
### Leaf nodes no longer receive inline `style.height`
Before v0.2.1-alpha, every node got `style.height` applied from `node.dimensions.height`. Now only nodes matching any of three container signals receive inline height:
1. The node has `childLayout` (auto-layout container)
2. The node is a parent of other nodes — some other node references it via `parentId` (e.g., group nodes with manually-positioned children)
3. The node has `fixedDimensions: true`
Plain leaf nodes let their content determine height.
**WireFlow-side impact:** if you were setting explicit `dimensions: ['width' => X, 'height' => Y]` on a leaf node in your `$nodes` array and expected the height to persist, add `'fixedDimensions' => true` to that node. Otherwise the value will be overwritten by measurement.
### Auto-promotion of `fixedDimensions`
Three server-triggered paths automatically set `fixedDimensions = true` on affected nodes so the intended height persists:
- `flowAnimate()` targeting `dimensions.height`
- Resize drag (client-side, user-initiated)
- `compute()` output (when compute writes a height)
### Layout dedup
At most one `layoutChildren` call per parent per animation frame. Any tests or tooling that counted duplicate layout calls per mutation should now expect lower counts.
### Client-local dimensions
`node.dimensions` updates reactively from the ResizeObserver — different clients may measure different values (fonts, zoom, DPI). **Dimensions are excluded from collab sync** if you're using Yjs collaboration; they're client-local state, not part of the canonical document.
## The rest of the engine pass (through the bundle)
The measurement/layout shifts above are the ones most likely to touch a Livewire app, but the resynced bundle carries the **whole** v0.2.1-alpha engine pass. The rest are summarized here; each links to the companion [AlpineFlow guide](https://artisanflow.dev/docs/alpineflow/migration/v0.2.1-alpha) for the before/after and full rationale. None require WireFlow-side code changes.
- **History & undo/redo** — selection is no longer undoable; identical consecutive states are deduped; drag captures on commit (not pointer-down); arrow-key nudges capture once per physical press. `undo()`/`redo()` now emit the `flow-restore` DOM event (`e.detail = { nodes, edges, origin: 'undo' | 'redo' }`).
- **Change & restore events** — `flow-restore` payload renamed its interim `source` tag to `origin` (`'undo' | 'redo' | 'load'`); `flow-nodes-change` / `flow-edges-change` gained `origin: 'drop' | 'paste' | 'api' | 'load'`. Your `onNodesChange` / `onEdgesChange` handlers receive `origin` automatically.
- **Edge routing** — avoidant edges use rounded corners instead of a Catmull-Rom spline (every avoidant `d` string changes); routes tie-break deterministically; obstacles burying an endpoint are excluded; hidden nodes stop being obstacles; edges simplify to a bezier during a node drag and snap back on drop; `animate()` settle re-runs edge geometry.
- **Viewport culling** — defaults to `'auto'`: at ≥150 nodes, off-screen nodes and edges get `display:none`.
- **Canvas lifecycle** — `destroy()` now actually runs (fullscreen exit, store unregister, listener/observer disposal — a long-dormant `onDestroy` handler fires for the first time); handle `pointerdown` is delegated in the capture phase (markup inside a handle no longer gets its own `pointerdown`).
- **Interaction & input** — double-click zoom can be a `'toggle'`; wheel zoom works over a node (`.nopan` gates panning only; `.nowheel` gates wheel); keyboard shortcuts no longer hijack `contenteditable` node bodies; drops over panels/controls/minimap/devtools cancel instead of adding a node.
- **Schema, export, fitView** — schema-edge geometry computes arithmetically by default (2.3–3.8× faster); `toImage()` now includes edges (and gains `scale` / `format` / `quality`); `fitView()` returns `Promise`.
### Config escape hatches you can pass through ``
Every engine-side default flip has an escape hatch you set in the canvas config your component hands to `` — no client JS required:
```php
// In your Livewire component
public array $config = [
'viewportCulling' => false, // always render (default 'auto' culls ≥150 nodes)
'avoidantSimplifyOnDrag' => false, // full routing during drags (default simplifies)
'delegatedHandleEvents' => false, // per-handle pointerdown listeners (default delegated)
'zoomOnDoubleClick' => 'step', // 'step' (default) | 'toggle' | false
'schemaHandleGeometry' => 'dom', // legacy DOM measurement (default 'auto')
'cullingAutoThreshold' => 500, // raise the auto-cull trip point (default 150)
];
```
```blade
```
## `$flow.batch(fn)` in Blade templates
For bulk client-side mutations from Alpine scope:
```blade
```
Server-driven mutations via the `WithWireFlow` trait don't need explicit batching — they already go through a single dispatch per call.
## Trait methods now keep server state in sync
Before v0.2.1-alpha, `flowAddNodes`, `flowRemoveNodes`, `flowAddEdges`, and `flowRemoveEdges` only dispatched to the client — your server-side `$this->nodes` and `$this->edges` arrays stayed untouched. This forced consumers to manually filter those arrays before/after each call to keep server and client in sync. Without the manual work, the next Livewire re-render would morph the stale server state back over the canvas (e.g., a removed node reappearing).
Now the trait mutates server-side arrays automatically (when they exist as public arrays), mirroring the client-side cascade behavior:
- `flowRemoveNodes` removes the targeted IDs, recursively removes all descendants (via `parentId` chain), and cascade-removes any edges whose `source` or `target` is a removed node
- `flowAddNodes` appends to `$this->nodes`
- `flowAddEdges` appends to `$this->edges`
- `flowRemoveEdges` removes matching `id` entries from `$this->edges`
**Before (manual):**
```php
#[On('node-deleted')]
public function onNodeDeleted(string $nodeId): void
{
$deletedEdgeIds = $this->deletedEdgesFor($nodeId);
$this->edges = collect($this->edges)
->filter(fn ($e) => $e['source'] !== $nodeId && $e['target'] !== $nodeId)
->values()
->all();
$this->flowRemoveEdges($deletedEdgeIds);
$this->nodes = collect($this->nodes)
->filter(fn ($n) => $n['id'] !== $nodeId)
->values()
->all();
$this->flowRemoveNodes([$nodeId]);
}
```
**After (the trait handles it):**
```php
#[On('node-deleted')]
public function onNodeDeleted(string $nodeId): void
{
$this->flowRemoveNodes([$nodeId]);
}
```
**Migration:** if your existing code manually filters `$nodes`/`$edges` before calling these methods, you can delete those manual filters. Keep them only if you need different cascade semantics than the client-side default. **Fallback:** if your component doesn't have `$nodes`/`$edges` as public array properties, the trait gracefully skips the server-side mutation and just dispatches (current behavior, fully backwards compatible).
## `` renders `wire:ignore` by default
The `` root now carries `wire:ignore`, so Livewire's DOM morphing skips the canvas subtree. Before, a Livewire re-render could morph — and tear down — the live canvas AlpineFlow had built on the client.
```blade
{{-- BEFORE: Livewire morphs could tear down the canvas subtree --}}
{{-- AFTER: subtree ignored by morph; canvas survives re-renders --}}
```
**Escape hatch.** If you deliberately relied on Livewire morphing the canvas internals, opt out with `:wire-ignore="false"`:
```blade
```
## `flowClear()` is destructive — a correction to the record
Some earlier notes described `$clear()` as a declared no-op. **That is false for v0.2.1-alpha** — the real implementation is present in the vendored bundle. `$this->flowClear()` dispatches `flow:clear`, which empties nodes and edges and resets the viewport to the origin. Treat it as destructive.
```php
$this->flowClear(); // dispatches flow:clear → canvas $clear()
// v0.2.1-alpha: ALREADY DESTRUCTIVE — empties nodes/edges, resets viewport.
// (A future engine resync layers a restore event with origin:'load' on top;
// the destructive behavior itself is already live in this bundle.)
```
## New trait methods — runState (D2)
Two new methods for driving node visual state from the server:
| Method | Description |
|--------|-------------|
| `flowSetNodeState(string\|array $ids, string $state)` | Set `runState` on one or more nodes. Mutates `$this->nodes` + dispatches `flow:setNodeState` |
| `flowResetStates()` | Clear `runState` from all nodes. Mutates `$this->nodes` + dispatches `flow:resetStates` |
Valid states: `pending` (baseline, no class) `running`, `completed`, `failed`, `skipped`.
```php
// Drive a subscription workflow from the server
#[On('job-queued')]
public function onJobQueued(string $nodeId): void
{
$this->flowSetNodeState($nodeId, 'running');
}
#[On('job-finished')]
public function onJobFinished(string $nodeId, bool $success): void
{
$this->flowSetNodeState($nodeId, $success ? 'completed' : 'failed');
}
```
The theme automatically provides visual defaults — violet pulse (running), teal flash (completed), red pulse (failed), dim + grayscale (skipped). No per-project CSS required.
## Nothing changed in the rest of the trait
For clarity:
- All other `WithWireFlow` trait methods — unchanged (same signatures, same events)
- `` Blade component — unchanged apart from the `wire:ignore` default above
- Wire-bridge dispatches — unchanged
- `#[Renderless]` / event listener patterns — unchanged
Your existing WireFlow code continues to work without modification. The bundled AlpineFlow engine improvements surface entirely on the client side.
## Tier B — API improvements (also included)
The same bundle includes six independent API additions from AlpineFlow's Tier B chapter. No WireFlow-side changes — these surface in the `` canvas directly.
### B1 — `text-align: center` moved to themes
Structural CSS no longer carries the centering opinion. Each shipped theme (default, flux) opts in. If you use a bare custom theme, add `text-align: center` to your `.flow-node` rule.
### B2 — `getNodeElement(id)` + `getNodeIdFromElement(el)`
Public accessors on the canvas. Replace `document.querySelector('[data-flow-node-id="..."]')` with `$flow.getNodeElement(id)` and `$flow.getNodeIdFromElement(el)`.
### B3 — Enhanced drop zone
- `dropMimeTypes` config — accept custom MIME types (default `['application/alpineflow']`)
- Target detection now returns the **deepest** node under the cursor (not outermost)
- `.flow-canvas-drag-over` class auto-applied during valid drag-over
- `$flow.getNodeAtPoint(clientX, clientY)` public utility
### B4 — `.flow-node-dragging` class
Auto-applied during drag, removed on drop/cancel. Mirrors the existing `.flow-node-selected` pattern.
### B5 — `defaultEdgeType` config
Canvas-level default for edge type: `flowCanvas({ defaultEdgeType: 'smoothstep' })`. Per-edge `type` wins.
### B6 — Edge `class` forwarding to label
An edge's `class` now applies to both the SVG `` and the edge label `
`.
## Tier C — Convenience & Polish (also included)
Four additional items from AlpineFlow's Tier C chapter. No WireFlow-side API changes — all surface directly in the `` canvas.
### C1 — `data-flow-node-type` attribute
Node DOM elements now carry `data-flow-node-type="..."` sourced from `node.type`. Style nodes by type with plain CSS: `[data-flow-node-type="page"] { ... }` — no Alpine class bindings required.
### C2 — Auto-layout excludes parented nodes *(alpha-breaking default flip)*
`layout()`, `forceLayout()`, `treeLayout()`, and `elkLayout()` now filter out nodes with `parentId` before passing to the layout engine. Their positions are managed by `childLayout` and were never meant to participate in top-level auto-layout. Pass `{ includeChildren: true }` to restore the previous behavior.
### C3 — fitView vertical centering
Regression guard added for correct vertical centering with varying-height nodes. (No behavioral change — investigation confirmed the math was already correct, likely resolved by Tier A's border-box fix.)
### C4 — Node-type connection rules
New canvas-level `connectionRules` config filters connections by node type. Runs BEFORE handle-level validation (`x-flow-handle-validate`) — both must pass. Supports both map-based rules and a custom validate function:
```php
// In your Livewire component's flowCanvas config
$config = [
'connectionRules' => [
'byType' => [
'page' => ['page'], // pages can only connect to pages
'field' => [], // fields cannot connect to anything
],
// Or function-based for complex rules (JS evaluation via JsRaw, if exposed)
],
];
```
Also applies to `addEdges` — programmatic edge additions are now filtered too, so server-side `flowAddEdges()` calls respect the same rules.
## Workflow addon
The AlpineFlow workflow addon (`@getartisanflow/alpineflow/workflow`) is now available through WireFlow. The install command will prompt you to add it:
```bash
# Interactive — prompts for addon selection
php artisan wireflow:install --force
# Non-interactive — explicitly include workflow
php artisan wireflow:install --force --no-interaction --with=workflow
# Non-interactive — core only (no addons)
php artisan wireflow:install --force --no-interaction
```
The addon adds `$flow.run()` for structured workflow execution. See the [AlpineFlow workflow addon reference](https://artisanflow.dev/docs/alpineflow/addons/workflow) for the complete API.
## Toolbar components validate enum props (alpha-breaking)
`` and `` now validate their enum props in the constructor and throw `InvalidArgumentException` (naming the valid values) on an unrecognized value, instead of silently passing it through and falling back to the default.
| Prop | Component | Valid values |
| --- | --- | --- |
| `position` | `` | `top`, `bottom`, `left`, `right` |
| `align` | `` | `center`, `start`, `end` |
| `show` | both | `selected`, `always` |
**Most likely break — `align`.** Earlier docs incorrectly listed `align="left"` / `align="right"`; those values were never recognized by the underlying directive and silently centered. They now throw. Alignment is flow-relative — for a `top`/`bottom` toolbar, `start` = left and `end` = right.
**Before** (silently centered — and now throws):
```blade
```
**After:**
```blade
```
If toolbars were rendering centered when you expected left/right alignment, this is why: switch `left` → `start` and `right` → `end`.
## Animation & particles (staged as v0.2.0-alpha)
These server-callable additions — particle emission, bulk animation control, the `flowHighlightPath` option pass-through, and the animation behavior changes inherited from AlpineFlow — were originally staged as a separate **v0.2.0-alpha** that was never tagged. They reach you as part of this single v0.1.x → v0.2.1-alpha upgrade.
### New trait methods
Four new particle firing methods and three new bulk-control methods are available on `WithWireFlow`:
#### Particle emission
```php
$this->flowSendParticleAlongPath(string $path, array $options = []);
$this->flowSendParticleBetween(string $source, string $target, array $options = []);
$this->flowSendParticleBurst(string $edgeId, array $options);
$this->flowSendConverging(array $edgeIds, array $options);
```
- `flowSendParticleAlongPath` — fires along an SVG `d` string; no edge required
- `flowSendParticleBetween` — fires along a straight line between two node centers
- `flowSendParticleBurst` — fires N particles on one edge with `count` + `stagger` options
- `flowSendConverging` — fires from several edges arriving at a target simultaneously
See [Particles → v0.2.0-alpha additions](../animation/particles.md#v0-2-0-alpha-additions) for examples.
#### Bulk animation control
```php
$this->flowCancelAll(array $filter = [], array $options = []);
$this->flowPauseAll(array $filter = []);
$this->flowResumeAll(array $filter = []);
```
`$filter` accepts `['tag' => 'name']` or `['tags' => ['a', 'b']]`. For `flowCancelAll`, `$options` accepts `['mode' => 'jump-end' | 'rollback' | 'freeze']` — matching the new AlpineFlow stop-mode contract.
Use case: start a tagged ambient animation (`flowAnimate(..., ['tag' => 'ambient', 'loop' => true])`), then pause/resume/cancel the whole group from server event handlers.
### Option pass-through fix for `flowHighlightPath`
**What changed.** Prior to v0.2.0-alpha, `flowHighlightPath()` silently dropped any option other than `color`, `size`, `duration`, and `delay`. Beam-renderer options (`renderer`, `gradient`, `followThrough`, `length`, `width`) and anything else passed in `$options` didn't reach the particle.
**After v0.2.0-alpha.** All options pass through transparently. Defaults are still applied for unset fields.
**Why.** Livewire users couldn't use the new beam gradient from `flowHighlightPath` without the fix — a visible gap between the advertised AlpineFlow capability and what Wireflow exposed.
```php
// Before: renderer and gradient silently ignored
$this->flowHighlightPath(['a', 'b', 'c'], [
'renderer' => 'beam',
'gradient' => [...], // dropped 😞
]);
// After: full pass-through
$this->flowHighlightPath(['a', 'b', 'c'], [
'renderer' => 'beam',
'length' => 50,
'width' => 3,
'gradient' => [
['offset' => 0, 'color' => '#8b5cf6', 'opacity' => 0],
['offset' => 1, 'color' => '#fff', 'opacity' => 1],
],
'duration' => 900,
'delay' => 200,
]);
```
No migration step needed — code that was using the (previously-ignored) extra options will start working.
### Breaking changes inherited from AlpineFlow
WireFlow ships the updated AlpineFlow bundle (`vendor/wireflow/dist/alpineflow.bundle.esm.js`), so the following AlpineFlow v0.2.0-alpha breaking changes apply to any WireFlow canvas. Full details with before/after in the [AlpineFlow migration guide](https://artisanflow.dev/docs/alpineflow/migration/v0.2.1-alpha):
- **Beam `duration` now includes follow-through** — `onComplete` fires after the tail exits. Escape hatch: `'followThrough' => false`
- **`loop: 'reverse'` renamed to `loop: 'ping-pong'`** — `'reverse'` kept as alias
- **`handle.reverse()` on finished handles now plays backward** — was no-op (canvas-level, not directly relevant server-side)
- **Superseded animations no longer fire `onComplete`** — if you were relying on `onComplete` firing when an animation was interrupted by a new one on the same keys, it won't anymore
- **`structuredClone` → `safeClone` with warn-once fallback** — fixes Alpine reactive proxy errors; no action needed
- **`ParticleHandle.getCurrentPosition()` reads internal state** — no user-facing change
- **`bounceStiffness`/`bounceDamping` recomputation** — inertia motion bouncing behavior adjusted to match Framer Motion's `bounciness × (1 - damping/100)` model
### New AlpineFlow capabilities now reachable from PHP
All through the existing `flowAnimate()` and `flowUpdate()` — no new trait methods required, pass via `$options`:
- **Physics motion** — `'motion' => 'spring.wobbly'` or `['type' => 'spring', 'stiffness' => 200]`
- **State-aware cancellation** — `'while'` predicate (but since predicates are JS-only, the server-friendly form is `'tag'` + `flowCancelAll()` from another handler)
- **Start state** — `'startAt' => 'end'` for reverse-from-target animations
- **On-lifecycle hooks** — `onStart` / `onProgress` / `onComplete` are JS-only and stripped when serialized server→client; use `tag` + `flowCancelAll()` or a delayed `dispatch` for server-side sequencing
- **Tagging** — `'tag' => 'name'` or `'tags' => ['a', 'b']` for grouping animations under `flowCancelAll`/`flowPauseAll`/`flowResumeAll`
### Not yet reachable from server (planned)
These AlpineFlow capabilities don't yet have server-side wrappers. Tracked for a future release:
- **`$flow.timeline()`** — client-only fluent builder. The server surface would need a JSON-serializable timeline description (steps, `parallel`, `await`/`when`/`else`, loop, tag inheritance) that the wire-bridge replays into `canvas.timeline()`. Use it from Alpine inside `` for now; server orchestration is possible today by chaining `flowAnimate()` calls with staggered `duration`s + `tag` for group cancel.
- **`$flow.transaction()`** — server-coordinated transaction with rollback (harder to express; needs architectural work on how server observes transaction state)
- **`$flow.group('name')`** — fluent group builder; can be approximated today via manual `'tag' => 'name'` on every animate call + `flowCancelAll(['tag' => 'name'])`
- **Per-handle controls** — `handle.pause()`/`resume()`/`reverse()` on a specific handle needs handle-by-id addressing; tracked as the "id-based controls" initiative
- **`$flow.record()` / `$flow.replay()`** — data-transfer pattern not yet defined (client → server? server → client as Recording JSON for demo loops?)
## Installation / upgrade
```bash
composer require getartisanflow/wireflow:^0.2.1-alpha
php artisan wireflow:install --force
npm run build
```
The install command now prompts for optional addons (workflow). In CI or automated environments, use `--no-interaction --with=workflow` to include addons without prompts.
The bundled AlpineFlow dist (`dist/alpineflow.bundle.esm.js`) now contains v0.2.1-alpha. The `--force` flag republishes JS/CSS assets into `public/vendor/alpineflow/` so your site picks up the new bundle.
If you import AlpineFlow directly via npm alongside WireFlow, bump `@getartisanflow/alpineflow` to `^0.2.1-alpha` and rebuild.
## Questions or issues?
File an issue at [github.com/getartisanflow/wireflow/issues](https://github.com/getartisanflow/wireflow/issues).