# Layout & Handles

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

**Site index:** [https://flows.2plot.dev/llms.txt](https://flows.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 13 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://flows.2plot.dev/sitemap.xml  


---



### 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):



```python
# 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/`](https://github.com/pip-install-python/dash-flows/tree/main/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.



```python
# 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**

- `layoutOptions` — a JSON string of ELK options (`elk.algorithm`, `elk.direction`, spacing keys); setting it from a callback re-runs the layout engine and repositions every node.
- `elk.algorithm` values shown here: `layered` (with `elk.direction` of `DOWN`/`RIGHT`/`UP`/`LEFT`), `org.eclipse.elk.force`, `org.eclipse.elk.radial`, and `org.eclipse.elk.stress`.
- All nodes start at `{"x": 0, "y": 0}` — ELK computes real positions on first render since `layoutOptions` is set in the initial component props.
- `Output("ex12-elk-flow", "nodes")` on the Reset button — pushes the nodes back to their original (unlaid-out) positions so you can re-apply a layout and see the change.
- The full example additionally renders the active `layoutOptions` JSON in a `Pre` block for inspection, alongside a short reference on when to use each algorithm.

#### 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.



```python
# 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**

- `smartHandles=True` — renders connection handles on all four sides of every node and picks whichever pair is closest for each edge, avoiding backtracking loops.
- With `smartHandles` off, all edges fall back to the default single top (target) / bottom (source) handle pair, which looks messy on diagonally arranged nodes.
- `Output("ex24-smart-flow", "smartHandles")` — flips the prop live from a `dcc.Checklist`, so you can compare routing with and without it on the same graph.
- `data.sourcePosition` / `data.targetPosition` — the full example also shows *manual* handle placement (e.g. `"right"` / `"left"`) for a strict left-to-right flow when you want precise control instead of automatic routing.
- `colorScheme="ocean"` — swaps in the built-in ocean palette for this demo.

#### 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.



```python
# 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**

- `helperLines=True` — enables the alignment-guide overlay; drag any node near another node's top/bottom/left/right edge or center line to see a blue guide and snap.
- `helperLineThreshold` — the snap distance in pixels; wired here to a `dcc.Slider` (1–20px) so you can feel the difference between a tight and loose snap radius live.
- `smartHandles=True` — combined with helper lines so edges keep routing cleanly even as you nudge nodes into alignment.
- `Output("ex27-helper-flow", "helperLines")` / `Output("ex27-helper-flow", "helperLineThreshold")` — both driven by the same callback, so the checkbox and slider update the running flow instantly.

#### 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.



```python
# 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**

- `animateLayout=True` — turns on eased position transitions whenever `layoutOptions` changes, instead of an instant re-layout.
- `animateLayoutDuration=500` — the transition length in milliseconds; nodes interpolate along an ease-out cubic curve.
- `layoutOptions` — swapped between four ELK presets (`layered` RIGHT, `layered` DOWN, `force`, `radial`) by the layout buttons.
- `viewportAction={"action": "fitView", "duration": 300, "delay": 550}` — re-fits the viewport just after the layout animation finishes, so the newly arranged graph stays centered.
- `dcc.Store` + `ctx.triggered_id` — tracks which layout button was last clicked so its style can be highlighted as "active" via a second callback.


---

*Source: /layout*
