LiveFlow
Interactive node-based flow diagrams for Phoenix LiveView.
Build visual node editors, workflow builders, and interactive diagrams — similar to React Flow, but for Phoenix LiveView.
Features
- Pan & Zoom — Mouse wheel zoom, drag-to-pan, fit-to-view, minimap-style controls
- Node Drag — Drag nodes with grid snapping and helper lines (alignment guides)
- Connections — Source/target handles with live connection preview and validation
- Selection — Click, Shift+click, and selection box (lasso) for multi-select
- Custom Nodes — Function components or LiveComponents as custom node types
- Node Resizing — Built-in
NodeResizercomponent for interactive node resize with handles - Edge Types — Bezier, straight, step, and smoothstep paths with animated edges
- Edge Labels — Inline-editable labels on edges (double-click to edit)
- Undo/Redo — Snapshot-based history with configurable max entries
- Copy/Paste — Clipboard with copy, cut, paste, and duplicate
- Serialization — Export/import flow state as JSON
- Collaboration — Real-time multi-user editing via PubSub with cursor sharing
- Validation — Composable connection validators (no duplicates, no cycles, type compatibility, max connections)
- Auto Layout — Tree, force-directed and collision layouts in Elixir, plus optional ELK (
mix live_flow.gen.elk) - Themes — 36 built-in themes with Tailwind v4 plugin for customization
- Virtualization — Server-side viewport culling for 10K+ node flows (pure Elixir)
- Export — Client-side SVG/PNG export
- Touch Support — Pinch-to-zoom, two-finger pan, long-press selection
- Keyboard Shortcuts — Built-in shortcuts panel (
?key)
Installation
Add live_flow to your dependencies in mix.exs:
def deps do
[
{:live_flow, "~> 0.3.0"}
]
end
JavaScript Setup
In your assets/js/app.js, import and register the hook:
import { LiveFlowHook, FileImportHook, setupDownloadHandler } from "live_flow"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {
LiveFlow: LiveFlowHook,
FileImport: FileImportHook // optional
}
})
// Optional: enable JSON file download
setupDownloadHandler()
Auto Layout with ELK (Optional)
The client-side ELK layout (lf:layout_data, lf:auto-layout) is not
bundled by default because elkjs adds about 1.6 MB to app.js. Add it with:
mix live_flow.gen.elk
It copies elkjs to assets/vendor/elk.bundled.cjs and registers it in assets/js/app.js
(registerElk(ELK)). The layouts in LiveFlow.Layout (tree/2, force/2,
resolve_collisions/2) run on the server and don't need it.
CSS Setup
Import the LiveFlow stylesheet in your assets/css/app.css:
@import "../../deps/live_flow/assets/css/live_flow.css";
Theme Setup (Optional)
To use the built-in themes, add the Tailwind v4 plugin:
@plugin "../../deps/live_flow/assets/js/live_flow/liveflow-theme" {
name: "light";
default: true;
}
@plugin "../../deps/live_flow/assets/js/live_flow/liveflow-theme" {
name: "dark";
prefersdark: true;
}
Quick Start
defmodule MyAppWeb.FlowLive do
use MyAppWeb, :live_view
alias LiveFlow.{State, Node, Edge}
def mount(_params, _session, socket) do
flow = State.new(
nodes: [
Node.new("1", %{x: 100, y: 100}, %{label: "Start"}),
Node.new("2", %{x: 300, y: 200}, %{label: "Process"}),
Node.new("3", %{x: 500, y: 100}, %{label: "End"})
],
edges: [
Edge.new("e1", "1", "2"),
Edge.new("e2", "2", "3")
]
)
{:ok, assign(socket, flow: flow)}
end
def render(assigns) do
~H"""
<div class="h-[600px]">
<.live_component
module={LiveFlow.Components.Flow}
id="my-flow"
flow={@flow}
opts={%{controls: true, background: :dots, fit_view_on_init: true}}
/>
</div>
"""
end
# Node moves, measured sizes, resizes and removals
def handle_event("lf:node_change", %{"changes" => changes}, socket) do
flow = LiveFlow.Changes.NodeChange.apply_changes(socket.assigns.flow, changes)
{:noreply, assign(socket, flow: flow)}
end
# Edges removed with their delete button
def handle_event("lf:edge_change", %{"changes" => changes}, socket) do
flow = LiveFlow.Changes.EdgeChange.apply_changes(socket.assigns.flow, changes)
{:noreply, assign(socket, flow: flow)}
end
# New connections: validate, then add the edge
def handle_event("lf:connect_end", params, socket) do
case LiveFlow.Validation.Connection.validate_and_create(socket.assigns.flow, params) do
{:ok, edge} -> {:noreply, assign(socket, flow: State.add_edge(socket.assigns.flow, edge))}
{:error, _reason} -> {:noreply, socket}
end
end
# Pan and zoom
def handle_event("lf:viewport_change", params, socket) do
{:noreply, assign(socket, flow: State.update_viewport(socket.assigns.flow, params))}
end
# Selection
def handle_event("lf:selection_change", %{"nodes" => nodes, "edges" => edges}, socket) do
flow = socket.assigns.flow |> State.clear_selection() |> State.select_nodes(nodes)
flow = Enum.reduce(edges, flow, &State.select_edge(&2, &1, multi: true))
{:noreply, assign(socket, flow: flow)}
end
# Backspace/Delete with a selection (deletable: false elements are kept)
def handle_event("lf:delete_selected", _params, socket) do
{:noreply, assign(socket, flow: State.delete_selected(socket.assigns.flow))}
end
# The other lf: events (see the Events guide)
def handle_event("lf:" <> _event, _params, socket), do: {:noreply, socket}
# Node clicks arrive as a message
def handle_info({:lf_node_click, _node_id}, socket), do: {:noreply, socket}
end
Flow Options
The opts map supports the following options (see LiveFlow.Components.Flow
for the full reference):
| Option | Type | Default | Description |
|---|---|---|---|
controls |
boolean | false |
Show zoom controls (zoom in/out, fit view) |
background |
:dots | :lines | :cross | nil |
nil |
Background pattern |
background_gap / background_size |
number | {x, y} |
20 / 1 |
Pattern gap and dot/cross size |
minimap |
boolean | false |
Show minimap overlay |
minimap_pannable / minimap_zoomable |
boolean | false |
Drag / scroll the minimap to move the viewport |
theme |
string | nil |
Theme name (e.g., "dark", "ocean") |
fit_view_on_init |
boolean | false |
Fit all nodes on mount |
fit_view_options |
map | %{} |
padding, duration, min_zoom, max_zoom, nodes, include_hidden_nodes |
min_zoom / max_zoom |
number | 0.1 / 4.0 |
Zoom limits |
pan_on_drag |
boolean | [button] |
true |
Pan by dragging the pane (true = left + middle button) |
pan_on_scroll |
boolean | false |
Scroll pans instead of zooming (pan_on_scroll_mode, pan_on_scroll_speed) |
pan_activation_key |
key | "Space" |
Hold to pan from anywhere (e.g. "Control") |
zoom_on_scroll / zoom_on_pinch / zoom_on_double_click |
boolean | true |
Zoom gestures |
snap_to_grid / snap_grid |
boolean / {x, y} |
false / {15, 15} |
Snap positions (and resizing) to a grid |
node_extent |
{{x1, y1}, {x2, y2}} |
nil |
Area nodes can't be dragged or resized out of |
node_drag_threshold |
integer | 1 |
Pixels before a drag starts |
nodes_draggable / nodes_connectable / elements_selectable |
boolean | true |
Global interaction switches |
selection_on_drag |
boolean | false |
Dragging the pane draws a selection box |
selection_mode |
:partial | :full |
:partial |
Select nodes touched by / fully inside the box |
selection_key / multi_selection_key |
key | "Shift" / ["Meta", "Control", "Shift"] |
Box selection / additive selection keys |
delete_key_code |
key | ["Backspace", "Delete"] |
Keys deleting the selection (nil disables) |
disable_keyboard_a11y |
boolean | false |
Disable moving selected nodes with the arrow keys |
auto_pan_on_node_drag / auto_pan_on_connect / auto_pan_on_selection |
boolean | true |
Pan when dragging close to the edge |
connection_mode |
:strict | :loose |
:strict |
:loose also connects source to source |
connection_line_type |
edge type | :bezier |
Path of the connection preview |
connection_radius |
number | 20 |
Snap to the closest handle within this radius |
elevate_nodes_on_select / elevate_edges_on_select |
boolean | true |
Render selected elements on top (z_index_mode: :manual disables it) |
helper_lines |
boolean | false |
Show alignment guides during drag |
cursors |
boolean | false |
Show remote cursors (for collaboration) |
virtualization |
boolean | false |
Server-side viewport culling for large flows |
Key options accept a single key, a combination of any length ("Meta+Shift+s")
or a list of them.
Custom Node Types
You can define custom node types using function components:
defp my_custom_node(assigns) do
~H"""
<div class="bg-white rounded-lg shadow-lg p-4 border-2 border-blue-500">
<LiveFlow.Components.Handle.handle type={:target} position={:top} />
<div class="font-bold"><%= @node.data[:label] %></div>
<div class="text-sm text-gray-500"><%= @node.data[:description] %></div>
<LiveFlow.Components.Handle.handle type={:source} position={:bottom} />
</div>
"""
end
Pass custom node types to the Flow component:
<.live_component
module={LiveFlow.Components.Flow}
id="my-flow"
flow={@flow}
node_types={%{custom: &my_custom_node/1}}
/>
Node Resizing
Add resize handles to custom nodes with the NodeResizer component:
defp my_resizable_node(assigns) do
~H"""
<div style="width: 100%; height: 100%; min-height: 80px; padding: 16px;">
<LiveFlow.Components.NodeResizer.node_resizer node_id={@node.id} min_width={120} min_height={60} />
<LiveFlow.Components.Handle.handle handle={LiveFlow.Handle.target(:left)} node_id={@node.id} />
<div>Resize me!</div>
<LiveFlow.Components.Handle.handle handle={LiveFlow.Handle.source(:right)} node_id={@node.id} />
</div>
"""
end
Create resizable nodes with the resizable: true option:
Node.new("r1", %{x: 100, y: 100}, %{label: "Resizable"},
type: :resizable,
resizable: true,
handles: [Handle.target(:left), Handle.source(:right)]
)
Collaboration
Enable real-time collaboration with PubSub:
def mount(_params, _session, socket) do
socket =
socket
|> assign(flow: initial_flow())
|> LiveFlow.Collaboration.join("flow:room-1",
pubsub: MyApp.PubSub,
presence: MyAppWeb.Presence # optional
)
{:ok, socket}
end
# Add to your LiveView:
def handle_info(msg, socket) do
LiveFlow.Collaboration.handle_info(msg, socket)
end
Virtualization (Large Flows)
For flows with hundreds or thousands of nodes, enable server-side viewport culling:
<.live_component
module={LiveFlow.Components.Flow}
id="my-flow"
flow={@flow}
opts={%{virtualization: true, controls: true}}
/>
With virtualization enabled, only nodes visible in the current viewport are rendered to the DOM. This is pure Elixir logic — the server computes which nodes overlap the visible rectangle and filters the render list. Results:
- 500 nodes: ~108 in DOM (78% reduction)
- 1,000 nodes: ~108 in DOM (89% reduction)
- 10,000 nodes: ~180 in DOM (98% reduction, no browser crash)
A max_visible cap (default 500) prevents browser freezes when zoomed out to show all nodes at once.
Showcase App
The flow_example/ directory contains a Phoenix 1.8 application that
showcases every feature: a playground, the interaction options and all the
React Flow examples — including the Pro
ones — rebuilt with LiveFlow (nodes, edges, interaction, sub-flows &
grouping, layouts, styling, whiteboard tools and exports). It depends on the library through a
path dependency, so library changes are picked up live:
cd flow_example
mix setup
mix phx.server
Then visit localhost:4000.
Documentation
Full documentation is available at HexDocs. Guides:
- Getting Started
- Custom Nodes and Edges
- Interaction and Events & JavaScript API
- Sub-flows & Grouping and Layout
- Built-in Components and Saving, Import & Export
- Real-Time Collaboration and Themes
License
MIT License. See LICENSE.md for details.