Automatic ELK layouts, smart handles, helper lines, and animated layout transitions.

Layout & Handles

Automatic ELK layouts, smart handles, helper lines, and animated layout transitions.


Overview

Arrange graphs automatically with ELK by passing layoutOptions, and animate the transition with animateLayout. smartHandles auto-routes edges to the closest node side, while helperLines shows alignment guides as you drag.

Live demo

Drag the nodes, pan, and zoom — the canvas below is a real, running DashFlows component (no callbacks, just the rendered graph):

# File: docs/layout/demo.py

"""Live, callback-free demo for the docs page. Rendered via `.. exec::docs.layout.demo`."""
import dash_flows

nodes = [{'id': 'root', 'type': 'input', 'data': {'label': 'Root'}, 'position': {'x': 200, 'y': 20}},
 {'id': 'l1', 'type': 'default', 'data': {'label': 'Branch 1'}, 'position': {'x': 60, 'y': 160}},
 {'id': 'l2', 'type': 'default', 'data': {'label': 'Branch 2'}, 'position': {'x': 340, 'y': 160}},
 {'id': 'l3', 'type': 'output', 'data': {'label': 'Leaf A'}, 'position': {'x': 20, 'y': 300}},
 {'id': 'l4', 'type': 'output', 'data': {'label': 'Leaf B'}, 'position': {'x': 160, 'y': 300}},
 {'id': 'l5', 'type': 'output', 'data': {'label': 'Leaf C'}, 'position': {'x': 340, 'y': 300}}]

edges = [{'id': 'r1', 'source': 'root', 'target': 'l1'},
 {'id': 'r2', 'source': 'root', 'target': 'l2'},
 {'id': '13', 'source': 'l1', 'target': 'l3'},
 {'id': '14', 'source': 'l1', 'target': 'l4'},
 {'id': '25', 'source': 'l2', 'target': 'l5'}]

component = dash_flows.DashFlows(
    id="layout-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=True,
    smartHandles=True,
    fitView=True,
    colorMode='system',
)

:defaultExpanded: false :withExpandedButton: true

Examples

Each example below is a complete, runnable Dash app from the examples/ folder. Run any of them with python examples/<file>.

ELK Layouts

Automatic graph layout with the ELK engine (layered, tree, radial, force, and stress). Pick an algorithm, click Apply, and watch the whole graph rearrange — useful whenever nodes are added dynamically and you don't want to hand-place positions.

# File: examples/12_elk_layouts.py

"""
Example 12: ELK Automatic Layouts
=================================
This example demonstrates automatic graph layouts using ELK:
- Layered (hierarchical) layout
- Force-directed layout
- Radial layout
- Different layout directions
"""

import dash
from dash import html, Input, Output, State, callback
import dash_flows
import dash_mantine_components as dmc
import json

app = dash.Dash(__name__)

# Create a more complex graph to show layout differences
initial_nodes = [
    {"id": "1", "type": "input", "data": {"label": "Start"}, "position": {"x": 0, "y": 0}},
    {"id": "2", "type": "default", "data": {"label": "Step A"}, "position": {"x": 0, "y": 0}},
    {"id": "3", "type": "default", "data": {"label": "Step B"}, "position": {"x": 0, "y": 0}},
    {"id": "4", "type": "default", "data": {"label": "Step C"}, "position": {"x": 0, "y": 0}},
    {"id": "5", "type": "default", "data": {"label": "Step D"}, "position": {"x": 0, "y": 0}},
    {"id": "6", "type": "default", "data": {"label": "Step E"}, "position": {"x": 0, "y": 0}},
    {"id": "7", "type": "default", "data": {"label": "Step F"}, "position": {"x": 0, "y": 0}},
    {"id": "8", "type": "output", "data": {"label": "End"}, "position": {"x": 0, "y": 0}},
]

initial_edges = [
    {"id": "e1-2", "source": "1", "target": "2"},
    {"id": "e1-3", "source": "1", "target": "3"},
    {"id": "e2-4", "source": "2", "target": "4"},
    {"id": "e2-5", "source": "2", "target": "5"},
    {"id": "e3-5", "source": "3", "target": "5"},
    {"id": "e3-6", "source": "3", "target": "6"},
    {"id": "e4-7", "source": "4", "target": "7"},
    {"id": "e5-7", "source": "5", "target": "7"},
    {"id": "e6-7", "source": "6", "target": "7"},
    {"id": "e7-8", "source": "7", "target": "8"},
]

layout_presets = {
    "layered-down": {
        "elk.algorithm": "layered",
        "elk.direction": "DOWN",
        "elk.spacing.nodeNode": 50,
        "elk.layered.spacing.nodeNodeBetweenLayers": 80,
    },
    "layered-right": {
        "elk.algorithm": "layered",
        "elk.direction": "RIGHT",
        "elk.spacing.nodeNode": 50,
        "elk.layered.spacing.nodeNodeBetweenLayers": 120,
    },
    "layered-up": {
        "elk.algorithm": "layered",
        "elk.direction": "UP",
        "elk.spacing.nodeNode": 50,
    },
    "layered-left": {
        "elk.algorithm": "layered",
        "elk.direction": "LEFT",
        "elk.spacing.nodeNode": 50,
    },
    "force": {
        "elk.algorithm": "org.eclipse.elk.force",
        "elk.force.iterations": 300,
        "elk.spacing.nodeNode": 80,
    },
    "radial": {
        "elk.algorithm": "org.eclipse.elk.radial",
        "elk.radial.radius": 150,
    },
    "stress": {
        "elk.algorithm": "org.eclipse.elk.stress",
        "elk.spacing.nodeNode": 100,
    },
}

app.layout = dmc.MantineProvider([
    html.H1("ELK Automatic Layouts Example"),
    html.P("Apply different automatic layout algorithms to arrange nodes."),

    dmc.Group([
        dmc.Select(
            id="layout-select",
            label="Select Layout Algorithm",
            data=[
                {"value": "layered-down", "label": "Layered (Top to Bottom)"},
                {"value": "layered-right", "label": "Layered (Left to Right)"},
                {"value": "layered-up", "label": "Layered (Bottom to Top)"},
                {"value": "layered-left", "label": "Layered (Right to Left)"},
                {"value": "force", "label": "Force-Directed"},
                {"value": "radial", "label": "Radial"},
                {"value": "stress", "label": "Stress"},
            ],
            value="layered-down",
            style={"width": 250},
        ),
        dmc.Button("Apply Layout", id="apply-layout-btn", variant="filled"),
        dmc.Button("Reset Positions", id="reset-btn", variant="outline"),
    ], style={"marginBottom": 20}),

    dash_flows.DashFlows(
        id="elk-flow",
        nodes=initial_nodes,
        edges=initial_edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        layoutOptions=json.dumps(layout_presets["layered-down"]),
    ),

    dmc.Space(h=20),

    dmc.Paper([
        dmc.Text("Current Layout Options:", fw=600),
        html.Pre(id="layout-options-display", style={"fontSize": "11px"}),
    ], p="md", withBorder=True),

    dmc.Space(h=20),

    dmc.Alert([
        dmc.Text("About ELK Layouts:", fw=600),
        dmc.List([
            dmc.ListItem([dmc.Text("Layered", fw=600), ": Best for hierarchical/directed graphs. Supports multiple directions."]),
            dmc.ListItem([dmc.Text("Force-Directed", fw=600), ": Simulates physical forces for organic layouts."]),
            dmc.ListItem([dmc.Text("Radial", fw=600), ": Arranges nodes in concentric circles from a root."]),
            dmc.ListItem([dmc.Text("Stress", fw=600), ": Minimizes stress in edge lengths for balanced layouts."]),
        ], size="sm"),
    ], color="gray", variant="light"),
])

@callback(
    Output("elk-flow", "layoutOptions"),
    Output("layout-options-display", "children"),
    Input("apply-layout-btn", "n_clicks"),
    State("layout-select", "value"),
    prevent_initial_call=True,
)
def apply_layout(n_clicks, layout_type):
    options = layout_presets.get(layout_type, layout_presets["layered-down"])
    return json.dumps(options), json.dumps(options, indent=2)

@callback(
    Output("elk-flow", "nodes"),
    Input("reset-btn", "n_clicks"),
    prevent_initial_call=True,
)
def reset_positions(n_clicks):
    # Reset to initial scattered positions
    return initial_nodes

if __name__ == "__main__":
    app.run(debug=True, port=8063)

:defaultExpanded: false :withExpandedButton: true

How it works

Smart Handles

Auto-route edges to the nearest node side by rendering handles on all four sides, instead of forcing every connection through fixed top/bottom handles. Toggle it off to see the difference on a graph laid out in a loose, non-linear grid.

# File: examples/24_smart_handles.py

"""
Example 24: Smart Handle Positioning
=====================================
Demonstrates the smartHandles feature which automatically positions edge
connection points to the closest side of each node, preventing edges from
wrapping around nodes unnecessarily.

Also shows manual handle positioning via sourcePosition/targetPosition in node data.
"""

import dash
from dash import html, dcc, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)


# --- Nodes arranged in various positions to show smart routing ---
smart_nodes = [
    # Top-left cluster
    {
        "id": "source-1",
        "type": "input",
        "data": {"label": "Data Source", "sublabel": "API"},
        "position": {"x": 50, "y": 50},
    },
    # Top-right
    {
        "id": "process-1",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Schema Check"},
        "position": {"x": 400, "y": 30},
    },
    # Center
    {
        "id": "process-2",
        "type": "default",
        "data": {"label": "Transform", "sublabel": "Map & Filter"},
        "position": {"x": 250, "y": 200},
    },
    # Bottom-left
    {
        "id": "process-3",
        "type": "default",
        "data": {"label": "Aggregate", "sublabel": "Group By"},
        "position": {"x": 50, "y": 350},
    },
    # Far right - mid
    {
        "id": "process-4",
        "type": "default",
        "data": {"label": "Enrich", "sublabel": "Join External"},
        "position": {"x": 500, "y": 220},
    },
    # Bottom-right
    {
        "id": "output-1",
        "type": "output",
        "data": {"label": "Dashboard", "sublabel": "Visualization"},
        "position": {"x": 400, "y": 400},
    },
    # Bottom center
    {
        "id": "output-2",
        "type": "output",
        "data": {"label": "Database", "sublabel": "PostgreSQL"},
        "position": {"x": 200, "y": 430},
    },
]

smart_edges = [
    # Horizontal: left -> right (should use right/left handles)
    {"id": "e1", "source": "source-1", "target": "process-1", "type": "smoothstep"},
    # Diagonal: top-left -> center (should pick best side)
    {"id": "e2", "source": "source-1", "target": "process-2", "type": "smoothstep"},
    # Right -> far right (horizontal, should use right/left)
    {"id": "e3", "source": "process-1", "target": "process-4", "type": "smoothstep"},
    # Center -> bottom-left (should use left/bottom or bottom/top)
    {"id": "e4", "source": "process-2", "target": "process-3", "type": "smoothstep"},
    # Center -> far right (horizontal)
    {"id": "e5", "source": "process-2", "target": "process-4", "type": "smoothstep"},
    # Center -> bottom-right output
    {"id": "e6", "source": "process-2", "target": "output-1", "type": "smoothstep"},
    # Far right -> bottom-right output
    {"id": "e7", "source": "process-4", "target": "output-1", "type": "smoothstep"},
    # Bottom-left -> bottom-center output
    {"id": "e8", "source": "process-3", "target": "output-2", "type": "smoothstep"},
]


# --- Manual handle positioning example ---
manual_nodes = [
    # Horizontal flow: left to right
    {
        "id": "h-start",
        "type": "input",
        "data": {
            "label": "Start",
            "sourcePosition": "right",  # Manual: source handle on right side
        },
        "position": {"x": 0, "y": 100},
    },
    {
        "id": "h-middle",
        "type": "default",
        "data": {
            "label": "Process",
            "sourcePosition": "right",
            "targetPosition": "left",  # Manual: target handle on left side
        },
        "position": {"x": 250, "y": 100},
    },
    {
        "id": "h-end",
        "type": "output",
        "data": {
            "label": "End",
            "targetPosition": "left",
        },
        "position": {"x": 500, "y": 100},
    },
]

manual_edges = [
    {"id": "he1", "source": "h-start", "target": "h-middle", "type": "smoothstep"},
    {"id": "he2", "source": "h-middle", "target": "h-end", "type": "smoothstep"},
]


app.layout = html.Div([
    html.H1("Smart Handle Positioning"),
    html.P(
        "The smartHandles prop automatically routes edges to the closest side of each node, "
        "eliminating unnecessary wrapping and overlapping."
    ),

    # Toggle
    html.Div([
        html.H3("Automatic Smart Handles"),
        html.P(
            "Toggle smartHandles to see the difference. Without it, all edges connect "
            "from bottom (source) to top (target), causing messy routing on non-vertical layouts."
        ),
        dcc.Checklist(
            id="smart-toggle",
            options=[{"label": "Enable smartHandles", "value": "on"}],
            value=["on"],
            style={"fontSize": "14px", "marginBottom": "12px"},
        ),
        html.Div(id="smart-status", style={"fontSize": "12px", "color": "#666", "marginBottom": "8px"}),
        dash_flows.DashFlows(
            id="smart-flow",
            nodes=smart_nodes,
            edges=smart_edges,
            smartHandles=True,
            style={"height": "550px", "border": "1px solid #ddd"},
            fitView=True,
            fitViewOptions={"padding": 0.15},
            showControls=True,
            showMiniMap=True,
            colorScheme="ocean",
        ),
    ], style={"marginBottom": "40px"}),

    html.Hr(),

    # Manual positioning example
    html.Div([
        html.H3("Manual Handle Positioning"),
        html.P(
            "You can also manually set sourcePosition and targetPosition in node data "
            "for precise control. This example creates a left-to-right horizontal flow."
        ),
        html.Pre(
            '{"label": "Process", "sourcePosition": "right", "targetPosition": "left"}',
            style={
                "background": "#f5f5f5",
                "padding": "8px 12px",
                "borderRadius": "4px",
                "fontSize": "12px",
            },
        ),
        dash_flows.DashFlows(
            id="manual-flow",
            nodes=manual_nodes,
            edges=manual_edges,
            style={"height": "250px", "border": "1px solid #ddd"},
            fitView=True,
            fitViewOptions={"padding": 0.3},
            showControls=True,
            showBackground=True,
        ),
    ]),
])


@callback(
    Output("smart-flow", "smartHandles"),
    Output("smart-status", "children"),
    Input("smart-toggle", "value"),
)
def toggle_smart_handles(value):
    enabled = "on" in (value or [])
    status = "smartHandles=True (edges route to closest side)" if enabled else "smartHandles=False (default top/bottom handles)"
    return enabled, status


if __name__ == "__main__":
    app.run(debug=True, port=8024)

:defaultExpanded: false :withExpandedButton: true

How it works

Helper Lines

Alignment guides that appear as you drag a node near another node's edge, snapping it into place — the same kind of guide you'd get in a design tool like Figma.

# File: examples/27_helper_lines.py

"""
Example 27: Helper Lines (Alignment Guides)
=============================================
Demonstrates helper lines that appear as visual guides when dragging a node
near another node's edge. Helps users align nodes precisely.
"""

import dash
from dash import html, dcc, callback, Input, Output
import dash_flows

app = dash.Dash(__name__)

nodes = [
    {
        "id": "a",
        "type": "input",
        "data": {"label": "Start"},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "b",
        "type": "default",
        "data": {"label": "Process A"},
        "position": {"x": 300, "y": 50},
    },
    {
        "id": "c",
        "type": "default",
        "data": {"label": "Process B"},
        "position": {"x": 300, "y": 200},
    },
    {
        "id": "d",
        "type": "default",
        "data": {"label": "Process C"},
        "position": {"x": 550, "y": 125},
    },
    {
        "id": "e",
        "type": "output",
        "data": {"label": "End"},
        "position": {"x": 550, "y": 300},
    },
]

edges = [
    {"id": "e-ab", "source": "a", "target": "b", "type": "smoothstep"},
    {"id": "e-ac", "source": "a", "target": "c", "type": "smoothstep"},
    {"id": "e-bd", "source": "b", "target": "d", "type": "smoothstep"},
    {"id": "e-cd", "source": "c", "target": "d", "type": "smoothstep"},
    {"id": "e-de", "source": "d", "target": "e", "type": "smoothstep"},
]

app.layout = html.Div([
    html.H1("Helper Lines (Alignment Guides)"),
    html.P(
        "Drag any node and blue alignment guides will appear when it aligns "
        "with another node's edge. The node will snap to the alignment."
    ),
    html.Div([
        dcc.Checklist(
            id="helper-toggle",
            options=[{"label": " Enable helper lines", "value": "on"}],
            value=["on"],
            style={"fontSize": "14px", "marginBottom": "8px"},
        ),
        html.Label("Snap threshold (px): ", style={"fontSize": "14px"}),
        dcc.Slider(
            id="threshold-slider",
            min=1, max=20, step=1, value=5,
            marks={1: "1", 5: "5", 10: "10", 20: "20"},
        ),
    ], style={"marginBottom": "12px", "maxWidth": "400px"}),
    dash_flows.DashFlows(
        id="helper-flow",
        nodes=nodes,
        edges=edges,
        helperLines=True,
        helperLineThreshold=5,
        smartHandles=True,
        style={"height": "550px", "border": "1px solid #ddd"},
        fitView=True,
        fitViewOptions={"padding": 0.2},
        showControls=True,
        showBackground=True,
        backgroundVariant="dots",
    ),
])


@callback(
    Output("helper-flow", "helperLines"),
    Output("helper-flow", "helperLineThreshold"),
    Input("helper-toggle", "value"),
    Input("threshold-slider", "value"),
)
def update_helper_settings(toggle, threshold):
    enabled = "on" in (toggle or [])
    return enabled, threshold or 5


if __name__ == "__main__":
    app.run(debug=True, port=8027)

:defaultExpanded: false :withExpandedButton: true

How it works

Animated Layout

Smoothly interpolate node positions when a new ELK layout is applied, instead of nodes jumping straight to their new spot. Click between the layout buttons to see the pipeline graph animate.

# File: examples/31_animated_layout.py

"""
Example 31: Animated Layout Transitions
========================================
Demonstrates smooth animated transitions when switching between
different ELK layout algorithms. Nodes interpolate from their current
positions to the new layout positions using an ease-out cubic curve.
"""

import json
import dash
from dash import html, dcc, callback, Input, Output, State
import dash_flows

app = dash.Dash(__name__)

# Create a pipeline-like graph
nodes = [
    {"id": "src-1",  "type": "input",   "data": {"label": "Source A"},   "position": {"x": 0,   "y": 0}},
    {"id": "src-2",  "type": "input",   "data": {"label": "Source B"},   "position": {"x": 0,   "y": 100}},
    {"id": "proc-1", "type": "default", "data": {"label": "Validate"},   "position": {"x": 200, "y": 0}},
    {"id": "proc-2", "type": "default", "data": {"label": "Transform"},  "position": {"x": 200, "y": 100}},
    {"id": "proc-3", "type": "default", "data": {"label": "Enrich"},     "position": {"x": 200, "y": 200}},
    {"id": "merge",  "type": "default", "data": {"label": "Merge"},      "position": {"x": 400, "y": 100}},
    {"id": "filter", "type": "default", "data": {"label": "Filter"},     "position": {"x": 600, "y": 50}},
    {"id": "agg",    "type": "default", "data": {"label": "Aggregate"},  "position": {"x": 600, "y": 150}},
    {"id": "out-1",  "type": "output",  "data": {"label": "Dashboard"},  "position": {"x": 800, "y": 50}},
    {"id": "out-2",  "type": "output",  "data": {"label": "Report"},     "position": {"x": 800, "y": 150}},
]

edges = [
    {"id": "e1",  "source": "src-1",  "target": "proc-1"},
    {"id": "e2",  "source": "src-1",  "target": "proc-2"},
    {"id": "e3",  "source": "src-2",  "target": "proc-2"},
    {"id": "e4",  "source": "src-2",  "target": "proc-3"},
    {"id": "e5",  "source": "proc-1", "target": "merge"},
    {"id": "e6",  "source": "proc-2", "target": "merge"},
    {"id": "e7",  "source": "proc-3", "target": "merge"},
    {"id": "e8",  "source": "merge",  "target": "filter"},
    {"id": "e9",  "source": "merge",  "target": "agg"},
    {"id": "e10", "source": "filter", "target": "out-1"},
    {"id": "e11", "source": "agg",    "target": "out-2"},
]

# Layout presets
layouts = {
    "layered-lr": {
        "label": "← → Layered (LR)",
        "options": json.dumps({
            "elk.algorithm": "layered",
            "elk.direction": "RIGHT",
            "elk.spacing.nodeNode": "50",
            "elk.layered.spacing.nodeNodeBetweenLayers": "80",
        }),
    },
    "layered-tb": {
        "label": "↓ Layered (TB)",
        "options": json.dumps({
            "elk.algorithm": "layered",
            "elk.direction": "DOWN",
            "elk.spacing.nodeNode": "50",
            "elk.layered.spacing.nodeNodeBetweenLayers": "80",
        }),
    },
    "force": {
        "label": "⚛ Force",
        "options": json.dumps({
            "elk.algorithm": "force",
            "elk.spacing.nodeNode": "80",
            "elk.force.iterations": "300",
        }),
    },
    "radial": {
        "label": "◎ Radial",
        "options": json.dumps({
            "elk.algorithm": "radial",
            "elk.spacing.nodeNode": "60",
        }),
    },
}

_btn_base = {
    "padding": "8px 16px", "border": "1px solid #ddd",
    "borderRadius": "6px", "cursor": "pointer",
    "fontSize": "13px", "fontWeight": "500", "transition": "all 0.15s",
}
_btn_active = {**_btn_base, "background": "#3b82f6", "color": "white", "borderColor": "#3b82f6"}
_btn_idle   = {**_btn_base, "background": "#fff", "color": "#374151"}

app.layout = html.Div([
    html.H2("Animated Layout Transitions"),
    html.P(
        "Click a layout to animate all nodes to their new positions. "
        "The viewport auto-fits after each transition.",
        style={"color": "#666"},
    ),

    html.Div([
        html.Button(info["label"], id=f"btn-{key}", style=_btn_idle)
        for key, info in layouts.items()
    ] + [
        html.Span(id="active-layout-label", style={
            "padding": "8px 14px", "background": "#f0f0f0",
            "borderRadius": "6px", "fontSize": "12px", "color": "#555",
        }),
    ], style={"display": "flex", "gap": "8px", "alignItems": "center", "marginBottom": "12px"}),

    dcc.Store(id="active-layout-store", data=""),

    dash_flows.DashFlows(
        id="animated-layout-flow",
        nodes=nodes,
        edges=edges,
        fitView=True,
        animateLayout=True,
        animateLayoutDuration=500,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        style={"height": "550px"},
    ),
], style={"padding": "20px"})


@callback(
    Output("animated-layout-flow", "layoutOptions"),
    Output("animated-layout-flow", "viewportAction"),
    Output("active-layout-store",  "data"),
    Output("active-layout-label",  "children"),
    [Input(f"btn-{key}", "n_clicks") for key in layouts],
    prevent_initial_call=True,
)
def apply_layout(*_clicks):
    from dash import ctx
    triggered = ctx.triggered_id
    if not triggered:
        return dash.no_update, dash.no_update, dash.no_update, dash.no_update

    # e.g. "btn-layered-lr" → "layered-lr"
    key = triggered[len("btn-"):]
    if key not in layouts:
        return dash.no_update, dash.no_update, dash.no_update, dash.no_update

    # Fit view after layout animation completes
    viewport_action = {"action": "fitView", "duration": 300, "delay": 550}

    return (
        layouts[key]["options"],
        viewport_action,
        key,
        f"Layout: {layouts[key]['label']}",
    )


# Highlight the active layout button
for key in layouts:
    @callback(
        Output(f"btn-{key}", "style"),
        Input("active-layout-store", "data"),
    )
    def update_btn_style(active, _key=key):
        return _btn_active if active == _key else _btn_idle


if __name__ == "__main__":
    app.run(debug=True, port=8031)

:defaultExpanded: false :withExpandedButton: true

How it works


Source: /layout

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: