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

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:

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:

License

MIT License. See LICENSE.md for details.