LiveFlow is a flow diagram library for Phoenix LiveView. It provides interactive, node-based diagrams with dragging, connecting, zooming, and panning -- all powered by LiveView with a thin JavaScript hook layer.
Installation
Add live_flow to your dependencies in mix.exs:
defp deps do
[
{:live_flow, "~> 0.4", hex: :ex_live_flow}
]
endThen fetch and compile:
mix deps.get
JavaScript Hook Setup
LiveFlow requires a JavaScript hook to handle client-side interactions (pan, zoom,
drag, connections). Import and register it in your app.js:
// assets/js/app.js
import { LiveFlowHook, FileImportHook, setupDownloadHandler } from "live_flow"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {
LiveFlow: LiveFlowHook,
FileImport: FileImportHook,
// ...your other hooks
},
// ...
})
// Enable file download support (for JSON export)
setupDownloadHandler()CSS Import
Import the LiveFlow stylesheet in your CSS:
/* assets/css/app.css */
@import "live_flow/live_flow.css";If you want theme support via the Tailwind v4 plugin, see the Themes Guide.
Creating Your First Flow
1. Set Up the LiveView
Create a LiveView that initializes the flow state in mount/3:
defmodule MyAppWeb.FlowLive do
use MyAppWeb, :live_view
alias LiveFlow.{State, Node, Edge, Handle}
@impl true
def mount(_params, _session, socket) do
nodes = [
Node.new("node-1", %{x: 100, y: 100}, %{label: "Start"},
handles: [Handle.source(:right)]
),
Node.new("node-2", %{x: 400, y: 100}, %{label: "Process"},
handles: [Handle.target(:left), Handle.source(:right)]
),
Node.new("node-3", %{x: 700, y: 100}, %{label: "End"},
handles: [Handle.target(:left)]
)
]
edges = [
Edge.new("e1", "node-1", "node-2"),
Edge.new("e2", "node-2", "node-3")
]
flow = State.new(nodes: nodes, edges: edges)
{:ok, assign(socket, flow: flow)}
end
end2. Render the Flow Component
Use LiveFlow.Components.Flow as a LiveComponent in your template:
@impl true
def render(assigns) do
~H"""
<div class="h-screen">
<.live_component
module={LiveFlow.Components.Flow}
id="my-flow"
flow={@flow}
opts={%{
controls: true,
minimap: true,
background: :dots,
fit_view_on_init: true
}}
/>
</div>
"""
endThe Flow component must be wrapped in a container with a defined height.
3. Handle Events
LiveFlow sends events to the parent LiveView via pushEvent. You need handlers
for each event type. Here is a minimal set:
@impl true
def handle_event("lf:node_change", %{"changes" => changes}, socket) do
flow =
Enum.reduce(changes, socket.assigns.flow, fn change, acc ->
apply_node_change(acc, change)
end)
{:noreply, assign(socket, flow: flow)}
end
@impl true
def handle_event("lf:edge_change", %{"changes" => changes}, socket) do
flow =
Enum.reduce(changes, socket.assigns.flow, fn
%{"type" => "remove", "id" => id}, acc -> State.remove_edge(acc, id)
_change, acc -> acc
end)
{:noreply, assign(socket, flow: flow)}
end
@impl true
def handle_event("lf:connect_end", params, socket) do
case LiveFlow.Validation.Connection.validate_and_create(socket.assigns.flow, params) do
{:ok, edge} ->
flow = State.add_edge(socket.assigns.flow, edge)
{:noreply, assign(socket, flow: flow)}
{:error, _reason} ->
{:noreply, socket}
end
end
@impl true
def handle_event("lf:viewport_change", params, socket) do
flow = State.update_viewport(socket.assigns.flow, params)
{:noreply, assign(socket, flow: flow)}
end
@impl true
def handle_event("lf:selection_change", %{"nodes" => node_ids, "edges" => edge_ids}, socket) do
flow =
socket.assigns.flow
|> Map.put(:selected_nodes, MapSet.new(node_ids))
|> Map.put(:selected_edges, MapSet.new(edge_ids))
nodes =
Enum.reduce(flow.nodes, %{}, fn {id, node}, acc ->
Map.put(acc, id, %{node | selected: id in node_ids})
end)
edges =
Enum.reduce(flow.edges, %{}, fn {id, edge}, acc ->
Map.put(acc, id, %{edge | selected: id in edge_ids})
end)
flow = %{flow | nodes: nodes, edges: edges}
{:noreply, assign(socket, flow: flow)}
end
@impl true
def handle_event("lf:delete_selected", _params, socket) do
flow = State.delete_selected(socket.assigns.flow)
{:noreply, assign(socket, flow: flow)}
end
# Catch-all for other lf: events (connect_start, connect_move, connect_cancel, etc.)
@impl true
def handle_event("lf:" <> _event, _params, socket) do
{:noreply, socket}
end
# Private helper for applying node changes
defp apply_node_change(flow, %{"type" => "position", "id" => id, "position" => pos} = change) do
case Map.get(flow.nodes, id) do
nil -> flow
node ->
updated = %{node | position: %{x: pos["x"] / 1, y: pos["y"] / 1},
dragging: Map.get(change, "dragging", false)}
%{flow | nodes: Map.put(flow.nodes, id, updated)}
end
end
defp apply_node_change(flow, %{"type" => "dimensions", "id" => id} = change) do
case Map.get(flow.nodes, id) do
nil -> flow
node ->
updated = %{node | width: Map.get(change, "width"),
height: Map.get(change, "height"),
measured: true}
%{flow | nodes: Map.put(flow.nodes, id, updated)}
end
end
defp apply_node_change(flow, %{"type" => "remove", "id" => id}) do
State.remove_node(flow, id)
end
defp apply_node_change(flow, _change), do: flowEvent Reference
| Event | Payload | Description |
|---|---|---|
lf:node_change | %{"changes" => [change]} | Node position, dimensions, or removal |
lf:edge_change | %{"changes" => [change]} | Edge selection or removal |
lf:connect_end | %{"source" => id, "target" => id, ...} | A connection was completed |
lf:connect_start | %{"node_id" => id, "handle_id" => id} | User started dragging a connection |
lf:connect_cancel | %{} | Connection was cancelled |
lf:viewport_change | %{"x" => x, "y" => y, "zoom" => z} | Pan or zoom changed |
lf:selection_change | %{"nodes" => [ids], "edges" => [ids]} | Selection changed |
lf:delete_selected | %{} | User pressed delete key |
lf:edge_label_change | %{"id" => id, "label" => text} | Edge label was edited |
State Management Pattern
LiveFlow follows a strict pattern: the parent LiveView is the single source of truth for all flow state.
User interaction (JS)
|
v
pushEvent to parent LiveView
|
v
handle_event updates @flow
|
v
assign(socket, flow: flow)
|
v
Flow LiveComponent re-renders with new stateKey rules:
- All state-modifying events go to the parent LiveView via
pushEvent. - The parent updates the
@flowassign and the Flow component re-renders. - Never create authoritative state inside the LiveComponent -- it will be
overwritten on the next
update/2from the parent. - Client-side-only interactions (connection preview line, selection box) are handled entirely in JavaScript without server roundtrips.
Component Options
Pass options via the opts map:
<.live_component
module={LiveFlow.Components.Flow}
id="my-flow"
flow={@flow}
opts={%{
pan_on_drag: true, # Pan by dragging the canvas
pan_on_scroll: false, # Pan with unmodified wheel/trackpad input
zoom_on_scroll: true, # Legacy unmodified-wheel zoom when not panning
zoom_on_pinch: nil, # Trackpad/Ctrl/Cmd/touch pinch; nil inherits scroll zoom
min_zoom: 0.1, # Minimum zoom level
max_zoom: 4.0, # Maximum zoom level
snap_to_grid: false, # Snap node positions to grid
snap_grid: {15, 15}, # Grid size {x, y}
fit_view_on_init: false, # Fit view to content on mount
background: :dots, # Background: :dots, :lines, :cross, or nil
minimap: false, # Show minimap overlay
controls: false, # Show zoom +/- controls
helper_lines: false, # Show alignment guides on drag
theme: "dark", # LiveFlow theme name
cursors: false, # Show remote cursors (collaboration)
nodes_draggable: true, # Allow dragging nodes
nodes_connectable: true, # Allow creating connections
elements_selectable: true, # Allow selecting nodes/edges
delete_key_code: "Backspace" # Key to delete selected elements
}}
/>Nodes and Edges
Creating Nodes
# Basic node
Node.new("id", %{x: 100, y: 200}, %{label: "My Node"})
# Node with handles and options
Node.new("id", %{x: 100, y: 200}, %{label: "My Node"},
type: :custom,
handles: [
Handle.new(:target, :top),
Handle.new(:source, :bottom, id: "output-1"),
Handle.source(:right), # Shorthand
Handle.target(:left) # Shorthand
]
)Creating Edges
# Basic edge
Edge.new("e1", "source-node-id", "target-node-id")
# Edge with options
Edge.new("e2", "node-a", "node-b",
type: :straight, # :bezier (default), :straight, :step, :smoothstep
animated: true,
label: "connects to",
marker_end: %{type: :arrow}
)Programmatic Actions
Trigger actions from the server by pushing events to the client:
# Fit view to content
push_event(socket, "lf:fit_view", %{padding: 0.1, duration: 200})
# Download file
push_event(socket, "lf:download_file", %{
content: json_string,
filename: "flow.json",
type: "application/json"
})Trigger actions from HEEx with JS.dispatch:
<button phx-click={JS.dispatch("lf:auto-layout", to: "#my-flow")}>
Auto Layout
</button>
<button phx-click={JS.dispatch("lf:export-svg", to: "#my-flow")}>
Export SVG
</button>Next Steps
- Custom Nodes -- Render nodes with your own components
- Collaboration -- Add real-time multi-user editing
- Themes -- Customize the look and feel