# dash-flows — React Flow node graphs for Dash — full corpus

## Access policy

- Terms: these documents are free to fetch. A free account unlocks any gated document.
- Identity: agents may present a key by appending `?key=<value>` to any document URL. Get one: https://2plot.ai
- Rate: prefer ONE `/llms-full.txt` fetch over N per-page fetches. On 429, honour `Retry-After` and back off exponentially.
- Coordination: start at https://2plot.dev/llms.txt — one index enumerates every site; do not rediscover the network by crawling it.
- Crawler policy (mirrors /robots.txt): allowed: GPTBot, ClaudeBot, CCBot, Google-Extended, FacebookBot, Omgili, ByteSpider, Amazonbot, Applebot-Extended, meta-externalagent, AI2Bot, Diffbot, Timpibot, ImagesiftBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot, OAI-SearchBot, Perplexity-User, Googlebot, Bingbot, Slurp, DuckDuckBot, GoogleOther, Google-InspectionTool, Storebot-Google, AdsBot-Google.
- Accounting: every document read is logged with the requesting vendor (verified against published IP ranges where the operator publishes them). See https://2plot.dev/llms.txt

> dash-flows — React Flow (@xyflow/react 12) node-graph components for Plotly Dash: interactive flow diagrams with a glass-morphism theme, ELK layouts, custom node/edge types, undo/redo, sub-flows and full Dash callback integration. By Pip Install Python.

Generated from 14 pages. The page index lives at https://flows.2plot.dev/llms.txt and a compact briefing at https://flows.2plot.dev/llms-small.txt; each page also serves its own document at `<page>/llms.txt`.

---

<!-- / — https://flows.2plot.dev/llms.txt -->

# dash-flows — React Flow node graphs for Dash

> **React Flow (@xyflow/react) node-graph components for Plotly Dash** — glass-morphism theming, ELK layouts, DashIconify icons, and full Dash callback interoperability.

![dash-flows](assets/current_project.png)

---

## What is dash-flows?

**dash-flows** wraps [React Flow 12](https://reactflow.dev) (`@xyflow/react`) as a
[Plotly Dash](https://dash.plotly.com) component library. Build interactive,
node-based diagrams — workflows, pipelines, org charts, state machines — that
talk to your Python callbacks like any other Dash component.

- **React Flow 12.11** under the hood — nodes, edges, handles, minimap, controls, background
- **Glass-morphism theme** with light/dark/system color modes and 6 color schemes
- **ELK layouts** for automatic graph arrangement
- **Rich node & edge types** — default, input, output, group, resizable, circle, toolbar; straight, step, smoothstep, bezier, button, data, animated, floating
- **Deep Dash integration** — clicks, selection, connections, drag/drop, context menus, undo/redo, copy/paste, save/restore, image export — all as callback props
- **Dash 4.1+** compatible (React 18; docs site developed against 4.2+)

---

## Install

```bash
pip install dash-flows
```

```python
import dash
from dash import html
import dash_flows

app = dash.Dash(__name__)

app.layout = html.Div(
    dash_flows.DashFlows(
        id="flow",
        nodes=[
            {"id": "1", "type": "input",  "data": {"label": "Start"}, "position": {"x": 100, "y": 100}},
            {"id": "2", "type": "default","data": {"label": "Process"}, "position": {"x": 100, "y": 240}},
            {"id": "3", "type": "output", "data": {"label": "End"}, "position": {"x": 100, "y": 380}},
        ],
        edges=[
            {"id": "e1-2", "source": "1", "target": "2", "animated": True},
            {"id": "e2-3", "source": "2", "target": "3"},
        ],
        style={"height": "500px"},
        showControls=True,
        showMiniMap=True,
    ),
    style={"height": "100vh"},
)

if __name__ == "__main__":
    app.run(debug=True)
```

---

## Explore the docs

Use the sidebar to explore each area of the library. Every page shows live,
runnable examples with their source code:

- **Getting Started** — your first flow, nodes, edges, and styling
- **Nodes** — all built-in node types, handle configuration, Dash components & icons inside nodes
- **Edges** — every edge type, including floating edges
- **Canvas & Controls** — backgrounds, controls, minimap, viewport
- **Interactions & Selection** — clicks, context menus, connection validation & limits
- **Layout & Handles** — ELK layouts, smart handles, helper lines, animated layout
- **Save, Restore & Export** — persistence, image export, copy/paste
- **Drag & Drop** — palette drag-and-drop and add-node-on-edge-drop
- **Theming & UI** — color modes, color schemes, and UI building blocks
- **Advanced Features** — undo/redo, computing flows, sub-flows, viewport portal, accessibility
- **API Reference** — the full `DashFlows` prop table

---

*Built by [Pip Install Python](https://2plot.dev) · [GitHub](https://github.com/pip-install-python/dash-flows) · MIT licensed*

---

<!-- /advanced — https://flows.2plot.dev/advanced/llms.txt -->

# Advanced Features

> Undo/redo, computing flows, sub-flows, viewport portal, accessibility, and more.

---



### Overview

The advanced feature set: history with `enableUndoRedo` / `undoRedoAction`, topological traversal with `computeAction` / `computeResult`, collapsible sub-flows (`toggleCollapseNode` / `collapsedGroups`), floating annotations via `viewportOverlays`, resize constraints, and full ARIA/keyboard accessibility.

### 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/advanced/demo.py

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

nodes = [{'id': 'src', 'type': 'input', 'data': {'label': 'Input'}, 'position': {'x': 60, 'y': 40}},
 {'id': 'm1', 'type': 'default', 'data': {'label': 'Step 1'}, 'position': {'x': 60, 'y': 170}},
 {'id': 'm2', 'type': 'default', 'data': {'label': 'Step 2'}, 'position': {'x': 260, 'y': 170}},
 {'id': 'agg', 'type': 'default', 'data': {'label': 'Aggregate'}, 'position': {'x': 160, 'y': 300}},
 {'id': 'snk', 'type': 'output', 'data': {'label': 'Output'}, 'position': {'x': 160, 'y': 430}}]

edges = [{'id': 'a1', 'source': 'src', 'target': 'm1'},
 {'id': 'a2', 'source': 'src', 'target': 'm2'},
 {'id': 'a3', 'source': 'm1', 'target': 'agg', 'animated': True},
 {'id': 'a4', 'source': 'm2', 'target': 'agg'},
 {'id': 'a5', 'source': 'agg', 'target': 'snk'}]

component = dash_flows.DashFlows(
    id="advanced-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=True,
    enableUndoRedo=True,
    helperLines=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>`.

#### Complete Showcase

A large example combining multiple node types, edge types, a group node, ELK layout switching, and live selection/count panels in one app. The full app (examples/13_complete_showcase.py) also includes a light/dark theme toggle; the embedded twin below drops that wrapper since the docs page already supplies a theme provider.



```python
# File: examples/13_complete_showcase.py

"""
Example 13: Complete Feature Showcase
=====================================
A comprehensive example combining multiple DashFlows features:
- Multiple node types with custom styling
- Various edge types
- Interactive toolbar
- Dark mode toggle
- Layout controls
- DevTools panel
"""

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

app = dash.Dash(__name__, suppress_callback_exceptions=True)

# Comprehensive node configuration
initial_nodes = [
    # Input nodes
    {
        "id": "input-api",
        "type": "input",
        "data": {"label": "REST API", "sublabel": "External Data"},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "input-db",
        "type": "input",
        "data": {"label": "Database", "sublabel": "PostgreSQL"},
        "position": {"x": 50, "y": 180},
    },

    # Processing nodes
    {
        "id": "process-validate",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Schema Check"},
        "position": {"x": 250, "y": 50},
    },
    {
        "id": "process-transform",
        "type": "default",
        "data": {"label": "Transform", "sublabel": "ETL Pipeline"},
        "position": {"x": 250, "y": 180},
    },
    {
        "id": "process-merge",
        "type": "toolbar",
        "data": {
            "label": "Merge Data",
            "sublabel": "Click for actions",
            "toolbarPosition": "top",
        },
        "position": {"x": 450, "y": 115},
    },

    # Group node with children
    {
        "id": "group-ml",
        "type": "group",
        "data": {"label": "ML Pipeline"},
        "position": {"x": 650, "y": 30},
        "style": {"width": 280, "height": 220},
    },
    {
        "id": "ml-train",
        "type": "default",
        "data": {"label": "Train Model"},
        "position": {"x": 30, "y": 50},
        "parentId": "group-ml",
        "extent": "parent",
    },
    {
        "id": "ml-evaluate",
        "type": "default",
        "data": {"label": "Evaluate"},
        "position": {"x": 30, "y": 130},
        "parentId": "group-ml",
        "extent": "parent",
    },

    # Output nodes
    {
        "id": "output-dashboard",
        "type": "output",
        "data": {"label": "Dashboard", "sublabel": "Visualization"},
        "position": {"x": 450, "y": 300},
    },
    {
        "id": "output-export",
        "type": "output",
        "data": {"label": "Export", "sublabel": "CSV/JSON"},
        "position": {"x": 650, "y": 300},
    },
]

initial_edges = [
    {"id": "e1", "source": "input-api", "target": "process-validate", "animated": True},
    {"id": "e2", "source": "input-db", "target": "process-transform", "type": "smoothstep"},
    {"id": "e3", "source": "process-validate", "target": "process-merge"},
    {"id": "e4", "source": "process-transform", "target": "process-merge", "type": "step"},
    {"id": "e5", "source": "process-merge", "target": "ml-train", "type": "simplebezier"},
    {"id": "e6", "source": "ml-train", "target": "ml-evaluate"},
    {"id": "e7", "source": "process-merge", "target": "output-dashboard", "type": "button", "data": {"label": "Live", "showButton": True}},
    {"id": "e8", "source": "ml-evaluate", "target": "output-export"},
]

# Layout presets
layouts = {
    "horizontal": json.dumps({
        "elk.algorithm": "layered",
        "elk.direction": "RIGHT",
        "elk.spacing.nodeNode": 60,
        "elk.layered.spacing.nodeNodeBetweenLayers": 100,
    }),
    "vertical": json.dumps({
        "elk.algorithm": "layered",
        "elk.direction": "DOWN",
        "elk.spacing.nodeNode": 60,
    }),
    "radial": json.dumps({
        "elk.algorithm": "org.eclipse.elk.radial",
        "elk.radial.radius": 180,
    }),
}

# App layout
app.layout = dmc.MantineProvider(
    id="theme-provider",
    forceColorScheme="light",
    children=[
        # Header
        dmc.Paper([
            dmc.Group([
                dmc.Title("DashFlows Complete Showcase", order=2),
                dmc.Group([
                    dmc.ActionIcon(
                        id="theme-toggle",
                        variant="subtle",
                        size="lg",
                        children=dmc.Text(id="theme-icon", size="lg"),
                    ),
                ]),
            ], justify="space-between"),
        ], p="md", mb="md", withBorder=True),

        # Controls
        dmc.Paper([
            dmc.Group([
                dmc.SegmentedControl(
                    id="layout-control",
                    data=[
                        {"value": "none", "label": "Manual"},
                        {"value": "horizontal", "label": "Horizontal"},
                        {"value": "vertical", "label": "Vertical"},
                        {"value": "radial", "label": "Radial"},
                    ],
                    value="none",
                ),
                dmc.Switch(id="show-minimap", label="MiniMap", checked=True),
                dmc.Switch(id="show-controls", label="Controls", checked=True),
                dmc.Switch(id="show-devtools", label="DevTools", checked=False),
            ], gap="lg"),
        ], p="sm", mb="md", withBorder=True),

        # Main flow area
        dmc.Grid([
            dmc.GridCol([
                html.Div(id="flow-container"),
            ], span=12 if True else 9),
        ]),

        # Info panels
        dmc.Grid([
            dmc.GridCol([
                dmc.Paper([
                    dmc.Text("Selected Items", fw=600, mb="xs"),
                    html.Div(id="selection-display"),
                ], p="md", withBorder=True),
            ], span=4),
            dmc.GridCol([
                dmc.Paper([
                    dmc.Text("Node Count", fw=600, mb="xs"),
                    html.Div(id="node-count"),
                ], p="md", withBorder=True),
            ], span=4),
            dmc.GridCol([
                dmc.Paper([
                    dmc.Text("Edge Count", fw=600, mb="xs"),
                    html.Div(id="edge-count"),
                ], p="md", withBorder=True),
            ], span=4),
        ], mt="md"),
    ],
)

# Theme toggle
clientside_callback(
    """
    function(n, scheme) {
        if (!n) return [scheme, scheme === 'dark' ? 'O' : '*'];
        const newScheme = scheme === 'light' ? 'dark' : 'light';
        return [newScheme, newScheme === 'dark' ? 'O' : '*'];
    }
    """,
    Output("theme-provider", "forceColorScheme"),
    Output("theme-icon", "children"),
    Input("theme-toggle", "n_clicks"),
    State("theme-provider", "forceColorScheme"),
    prevent_initial_call=False,
)

# Render flow with settings
@callback(
    Output("flow-container", "children"),
    Input("layout-control", "value"),
    Input("show-minimap", "checked"),
    Input("show-controls", "checked"),
    Input("show-devtools", "checked"),
)
def render_flow(layout, show_mm, show_ctrl, show_dev):
    layout_opts = layouts.get(layout) if layout != "none" else None

    return dash_flows.DashFlows(
        id="showcase-flow",
        nodes=initial_nodes,
        edges=initial_edges,
        style={"height": "500px", "borderRadius": "8px"},
        fitView=True,
        showControls=show_ctrl,
        showMiniMap=show_mm,
        showDevTools=show_dev,
        layoutOptions=layout_opts,
        backgroundVariant="dots",
        elementsSelectable=True,
        nodesConnectable=True,
        nodesDraggable=True,
    )

# Selection display
@callback(
    Output("selection-display", "children"),
    Input("showcase-flow", "selectedNodes"),
    Input("showcase-flow", "selectedEdges"),
    prevent_initial_call=True,
)
def show_selection(nodes, edges):
    # Handle various data formats
    node_ids = []
    edge_ids = []

    if nodes:
        if isinstance(nodes, list):
            for n in nodes:
                if isinstance(n, dict) and "id" in n:
                    node_ids.append(n["id"])
                elif isinstance(n, str):
                    node_ids.append(n)

    if edges:
        if isinstance(edges, list):
            for e in edges:
                if isinstance(e, dict) and "id" in e:
                    edge_ids.append(e["id"])
                elif isinstance(e, str):
                    edge_ids.append(e)

    if not node_ids and not edge_ids:
        return dmc.Text("Nothing selected", c="dimmed", size="sm")

    items = []
    if node_ids:
        items.append(dmc.Group([dmc.Badge(nid, color="blue", size="sm") for nid in node_ids], gap="xs"))
    if edge_ids:
        items.append(dmc.Group([dmc.Badge(eid, color="grape", size="sm") for eid in edge_ids], gap="xs"))

    return dmc.Stack(items, gap="xs")

# Node count
@callback(
    Output("node-count", "children"),
    Input("showcase-flow", "nodes"),
)
def show_node_count(nodes):
    count = len(nodes) if nodes else 0
    return dmc.Text(f"{count} nodes", size="xl", fw=700)

# Edge count
@callback(
    Output("edge-count", "children"),
    Input("showcase-flow", "edges"),
)
def show_edge_count(edges):
    count = len(edges) if edges else 0
    return dmc.Text(f"{count} edges", size="xl", fw=700)

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `layoutOptions` — set from a `SegmentedControl` to switch between horizontal, vertical, and radial ELK layouts on the fly
- `showMiniMap` / `showControls` / `showDevTools` — toggled by switches, demonstrating that chrome can be turned on/off per render
- `selectedNodes` / `selectedEdges` — read in a callback to render badges for the current selection
- `nodes` / `edges` (as callback Inputs) — used purely to display live counts, showing that the props stay in sync with canvas state
- `type: "group"` with `parentId` / `extent: "parent"` children — nests the ML Pipeline nodes inside a group node
- `type: "toolbar"` and `type: "button"` edge — shows the ToolbarNode and ButtonEdge working alongside default/input/output nodes

#### Callback Stress Test

A performance harness that hammers the callback chain: a 5x10 node/edge grid, batch add/remove/shuffle/connect operations (up to hundreds of elements at once), and half a dozen simultaneous listeners on click/drag/selection/connection/deletion events, all while tracking a live metrics dashboard.

This example is intentionally **not embedded** as a live demo — running it inline would fire the same high-frequency callback storm against the whole docs page and degrade every other embedded example on it. Run it standalone with `python examples/23_callback_stress_test.py` instead.


```python
# File: examples/23_callback_stress_test.py

"""
Example 23: Callback Stress Test
================================
This example stress tests Dash callbacks when interacting with DashFlows:
- Large number of nodes and edges
- Multiple simultaneous callbacks monitoring different events
- Rapid node manipulation (add, remove, update)
- Stress testing drag, selection, and connection events
- Performance metrics tracking
- Batch operations on nodes/edges
"""

import dash
from dash import html, Input, Output, State, callback, ctx, dcc, ALL, MATCH
from dash.exceptions import PreventUpdate
import dash_flows
import dash_mantine_components as dmc
import json
import time
import random
import uuid

app = dash.Dash(__name__, suppress_callback_exceptions=True)

# Configuration
GRID_ROWS = 5
GRID_COLS = 10
NODE_SPACING_X = 180
NODE_SPACING_Y = 120

def generate_grid_nodes(rows, cols):
    """Generate a grid of nodes for stress testing."""
    nodes = []
    for row in range(rows):
        for col in range(cols):
            node_id = f"node-{row}-{col}"
            node_type = "default"
            if col == 0:
                node_type = "input"
            elif col == cols - 1:
                node_type = "output"

            nodes.append({
                "id": node_id,
                "type": node_type,
                "data": {
                    "label": f"N{row},{col}",
                    "sublabel": f"idx: {row * cols + col}"
                },
                "position": {
                    "x": 50 + col * NODE_SPACING_X,
                    "y": 50 + row * NODE_SPACING_Y
                },
            })
    return nodes

def generate_grid_edges(rows, cols):
    """Generate edges connecting adjacent nodes in the grid."""
    edges = []
    for row in range(rows):
        for col in range(cols - 1):
            source = f"node-{row}-{col}"
            target = f"node-{row}-{col + 1}"
            edges.append({
                "id": f"edge-{source}-{target}",
                "source": source,
                "target": target,
                "type": random.choice(["default", "smoothstep", "step"]),
            })
    return edges

initial_nodes = generate_grid_nodes(GRID_ROWS, GRID_COLS)
initial_edges = generate_grid_edges(GRID_ROWS, GRID_COLS)

# Stress test metrics store
metrics_store = {
    "callback_counts": {},
    "last_callback_times": {},
    "total_callbacks": 0,
}

app.layout = dmc.MantineProvider([
    dcc.Store(id="metrics-store", data={
        "callbacks_fired": 0,
        "nodes_added": 0,
        "nodes_removed": 0,
        "edges_added": 0,
        "edges_removed": 0,
        "drags_completed": 0,
        "selections_made": 0,
        "connections_made": 0,
        "start_time": time.time(),
    }),
    dcc.Interval(id="metrics-interval", interval=500, n_intervals=0),

    html.H1("DashFlows Callback Stress Test", style={"marginBottom": "10px"}),

    # Control Panel
    dmc.Paper([
        dmc.Group([
            dmc.Text("Stress Test Controls:", fw=700),
            dmc.Button("Add 10 Random Nodes", id="btn-add-nodes", color="green", size="xs"),
            dmc.Button("Remove 10 Random Nodes", id="btn-remove-nodes", color="red", size="xs"),
            dmc.Button("Shuffle All Positions", id="btn-shuffle", color="blue", size="xs"),
            dmc.Button("Connect Random Pairs", id="btn-connect", color="violet", size="xs"),
            dmc.Button("Select All", id="btn-select-all", color="cyan", size="xs"),
            dmc.Button("Clear Selection", id="btn-clear-selection", color="gray", size="xs"),
            dmc.Button("Reset Grid", id="btn-reset", color="orange", size="xs"),
        ], gap="xs"),
    ], p="sm", mb="sm", withBorder=True),

    # Batch Operations Panel
    dmc.Paper([
        dmc.Group([
            dmc.Text("Batch Operations:", fw=700),
            dmc.NumberInput(id="batch-size", value=50, min=10, max=500, step=10,
                          label="Batch Size", w=120, size="xs"),
            dmc.Button("Add Batch Nodes", id="btn-batch-add", color="teal", size="xs"),
            dmc.Button("Batch Update Labels", id="btn-batch-update", color="indigo", size="xs"),
            dmc.Button("Stress Connect All", id="btn-stress-connect", color="pink", size="xs"),
        ], gap="xs"),
    ], p="sm", mb="sm", withBorder=True),

    # Main Layout
    dmc.Grid([
        # Flow Canvas
        dmc.GridCol([
            dash_flows.DashFlows(
                id="stress-flow",
                nodes=initial_nodes,
                edges=initial_edges,
                style={"height": "500px", "border": "1px solid #ddd"},
                fitView=True,
                showControls=True,
                showMiniMap=True,
                miniMapPosition="bottom-left",
                nodesDraggable=True,
                nodesConnectable=True,
                elementsSelectable=True,
                selectNodesOnDrag=False,
                backgroundVariant="dots",
                connectionMode="loose",
            ),
        ], span=9),

        # Metrics Panel
        dmc.GridCol([
            dmc.Stack([
                dmc.Paper([
                    dmc.Text("Live Metrics", fw=700, size="lg", mb="xs"),
                    html.Div(id="live-metrics"),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Callback Activity", fw=700, size="lg", mb="xs"),
                    html.Div(id="callback-activity"),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Current State", fw=700, size="lg", mb="xs"),
                    html.Div(id="current-state"),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Last Events", fw=700, size="lg", mb="xs"),
                    html.Div(id="last-events", style={"maxHeight": "150px", "overflow": "auto"}),
                ], p="sm", withBorder=True),
            ], gap="xs"),
        ], span=3),
    ]),

    # Event Log
    dmc.Paper([
        dmc.Group([
            dmc.Text("Event Log (last 20):", fw=700),
            dmc.Button("Clear Log", id="btn-clear-log", size="xs", variant="subtle"),
        ], justify="space-between", mb="xs"),
        html.Div(id="event-log", style={
            "maxHeight": "150px",
            "overflow": "auto",
            "fontFamily": "monospace",
            "fontSize": "11px",
        }),
    ], p="sm", mt="sm", withBorder=True),

    # Hidden stores for event tracking
    dcc.Store(id="event-log-store", data=[]),
    dcc.Store(id="callback-counter", data=0),
])


# ============================================================================
# STRESS TEST CALLBACKS - Node Manipulation
# ============================================================================

@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-add-nodes", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def add_random_nodes(n_clicks, nodes, log, metrics):
    """Add 10 random nodes to stress test node addition."""
    if not n_clicks:
        raise PreventUpdate

    nodes = nodes or []
    new_nodes = []

    for i in range(10):
        node_id = f"rand-{uuid.uuid4().hex[:8]}"
        new_nodes.append({
            "id": node_id,
            "type": random.choice(["default", "input", "output"]),
            "data": {
                "label": f"New-{i}",
                "sublabel": f"Added #{n_clicks}"
            },
            "position": {
                "x": random.randint(50, 1500),
                "y": random.randint(50, 500)
            },
        })

    log = log or []
    log.insert(0, f"[ADD] Added 10 nodes (total: {len(nodes) + 10})")
    log = log[:20]

    metrics["nodes_added"] = metrics.get("nodes_added", 0) + 10
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return nodes + new_nodes, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("stress-flow", "edges", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-remove-nodes", "n_clicks"),
    State("stress-flow", "nodes"),
    State("stress-flow", "edges"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def remove_random_nodes(n_clicks, nodes, edges, log, metrics):
    """Remove 10 random nodes to stress test node removal."""
    if not n_clicks or not nodes or len(nodes) <= 10:
        raise PreventUpdate

    nodes_to_remove = set(random.sample([n["id"] for n in nodes], min(10, len(nodes))))

    remaining_nodes = [n for n in nodes if n["id"] not in nodes_to_remove]
    remaining_edges = [e for e in (edges or [])
                       if e["source"] not in nodes_to_remove
                       and e["target"] not in nodes_to_remove]

    log = log or []
    log.insert(0, f"[REMOVE] Removed 10 nodes (remaining: {len(remaining_nodes)})")
    log = log[:20]

    metrics["nodes_removed"] = metrics.get("nodes_removed", 0) + 10
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return remaining_nodes, remaining_edges, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-shuffle", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def shuffle_positions(n_clicks, nodes, log, metrics):
    """Shuffle all node positions to stress test batch position updates."""
    if not n_clicks or not nodes:
        raise PreventUpdate

    updated_nodes = []
    for node in nodes:
        updated_node = {**node}
        updated_node["position"] = {
            "x": random.randint(50, 1500),
            "y": random.randint(50, 500)
        }
        updated_nodes.append(updated_node)

    log = log or []
    log.insert(0, f"[SHUFFLE] Shuffled {len(nodes)} node positions")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return updated_nodes, log, metrics


@callback(
    Output("stress-flow", "edges", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-connect", "n_clicks"),
    State("stress-flow", "nodes"),
    State("stress-flow", "edges"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def connect_random_pairs(n_clicks, nodes, edges, log, metrics):
    """Create 10 random connections to stress test edge creation."""
    if not n_clicks or not nodes or len(nodes) < 2:
        raise PreventUpdate

    edges = edges or []
    existing_connections = {(e["source"], e["target"]) for e in edges}
    node_ids = [n["id"] for n in nodes]

    new_edges = []
    attempts = 0
    while len(new_edges) < 10 and attempts < 50:
        source = random.choice(node_ids)
        target = random.choice(node_ids)
        if source != target and (source, target) not in existing_connections:
            new_edges.append({
                "id": f"edge-{uuid.uuid4().hex[:8]}",
                "source": source,
                "target": target,
                "type": random.choice(["default", "smoothstep", "step"]),
                "animated": random.choice([True, False]),
            })
            existing_connections.add((source, target))
        attempts += 1

    log = log or []
    log.insert(0, f"[CONNECT] Created {len(new_edges)} new edges (total: {len(edges) + len(new_edges)})")
    log = log[:20]

    metrics["edges_added"] = metrics.get("edges_added", 0) + len(new_edges)
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return edges + new_edges, log, metrics


# ============================================================================
# BATCH OPERATIONS
# ============================================================================

@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-batch-add", "n_clicks"),
    State("batch-size", "value"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def batch_add_nodes(n_clicks, batch_size, nodes, log, metrics):
    """Add a large batch of nodes at once."""
    if not n_clicks:
        raise PreventUpdate

    batch_size = batch_size or 50
    nodes = nodes or []
    new_nodes = []

    for i in range(batch_size):
        node_id = f"batch-{uuid.uuid4().hex[:8]}"
        new_nodes.append({
            "id": node_id,
            "type": random.choice(["default", "input", "output"]),
            "data": {
                "label": f"B{i}",
                "sublabel": f"Batch #{n_clicks}"
            },
            "position": {
                "x": random.randint(50, 2000),
                "y": random.randint(50, 800)
            },
        })

    log = log or []
    log.insert(0, f"[BATCH ADD] Added {batch_size} nodes (total: {len(nodes) + batch_size})")
    log = log[:20]

    metrics["nodes_added"] = metrics.get("nodes_added", 0) + batch_size
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return nodes + new_nodes, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-batch-update", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def batch_update_labels(n_clicks, nodes, log, metrics):
    """Update all node labels to stress test batch updates."""
    if not n_clicks or not nodes:
        raise PreventUpdate

    updated_nodes = []
    for i, node in enumerate(nodes):
        updated_node = {**node}
        updated_node["data"] = {
            **node.get("data", {}),
            "label": f"U{i}-{n_clicks}",
            "sublabel": f"Updated #{n_clicks}"
        }
        updated_nodes.append(updated_node)

    log = log or []
    log.insert(0, f"[BATCH UPDATE] Updated {len(nodes)} node labels")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return updated_nodes, log, metrics


@callback(
    Output("stress-flow", "edges", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-stress-connect", "n_clicks"),
    State("stress-flow", "nodes"),
    State("stress-flow", "edges"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def stress_connect_all(n_clicks, nodes, edges, log, metrics):
    """Create many random connections to stress test edge handling."""
    if not n_clicks or not nodes or len(nodes) < 2:
        raise PreventUpdate

    edges = edges or []
    existing_connections = {(e["source"], e["target"]) for e in edges}
    node_ids = [n["id"] for n in nodes]

    # Create up to 100 new edges or 2x current nodes, whichever is smaller
    target_new_edges = min(100, len(nodes) * 2)
    new_edges = []
    attempts = 0

    while len(new_edges) < target_new_edges and attempts < target_new_edges * 5:
        source = random.choice(node_ids)
        target = random.choice(node_ids)
        if source != target and (source, target) not in existing_connections:
            new_edges.append({
                "id": f"stress-{uuid.uuid4().hex[:8]}",
                "source": source,
                "target": target,
                "type": random.choice(["default", "smoothstep", "step", "straight"]),
                "animated": random.choice([True, False, False, False]),
            })
            existing_connections.add((source, target))
        attempts += 1

    log = log or []
    log.insert(0, f"[STRESS CONNECT] Created {len(new_edges)} edges (total: {len(edges) + len(new_edges)})")
    log = log[:20]

    metrics["edges_added"] = metrics.get("edges_added", 0) + len(new_edges)
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return edges + new_edges, log, metrics


# ============================================================================
# SELECTION CALLBACKS
# ============================================================================

@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-select-all", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def select_all_nodes(n_clicks, nodes, log, metrics):
    """Select all nodes to stress test selection handling."""
    if not n_clicks or not nodes:
        raise PreventUpdate

    selected_nodes = [{**n, "selected": True} for n in nodes]

    log = log or []
    log.insert(0, f"[SELECT ALL] Selected {len(nodes)} nodes")
    log = log[:20]

    metrics["selections_made"] = metrics.get("selections_made", 0) + 1
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return selected_nodes, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-clear-selection", "n_clicks"),
    State("stress-flow", "nodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def clear_selection(n_clicks, nodes, log, metrics):
    """Clear selection on all nodes."""
    if not n_clicks or not nodes:
        raise PreventUpdate

    cleared_nodes = [{**n, "selected": False} for n in nodes]

    log = log or []
    log.insert(0, f"[CLEAR] Cleared selection on {len(nodes)} nodes")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return cleared_nodes, log, metrics


@callback(
    Output("stress-flow", "nodes", allow_duplicate=True),
    Output("stress-flow", "edges", allow_duplicate=True),
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("btn-reset", "n_clicks"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def reset_grid(n_clicks, log, metrics):
    """Reset to initial grid configuration."""
    if not n_clicks:
        raise PreventUpdate

    log = log or []
    log.insert(0, f"[RESET] Reset to initial {GRID_ROWS}x{GRID_COLS} grid")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return (
        generate_grid_nodes(GRID_ROWS, GRID_COLS),
        generate_grid_edges(GRID_ROWS, GRID_COLS),
        log,
        metrics
    )


# ============================================================================
# EVENT MONITORING CALLBACKS
# ============================================================================

@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "clickedNode"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_node_click(clicked_node, log, metrics):
    """Monitor node click events."""
    if not clicked_node:
        raise PreventUpdate

    node_id = clicked_node.get("id", "unknown") if isinstance(clicked_node, dict) else clicked_node

    log = log or []
    log.insert(0, f"[CLICK] Node: {node_id}")
    log = log[:20]

    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return log, metrics


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "draggedNode"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_node_drag(dragged_node, log, metrics):
    """Monitor node drag events."""
    if not dragged_node:
        raise PreventUpdate

    node_id = dragged_node.get("id", "unknown")
    is_dragging = dragged_node.get("isDragging", False)

    if not is_dragging:  # Only log when drag completes
        log = log or []
        end_pos = dragged_node.get("endPosition", {})
        log.insert(0, f"[DRAG] Node {node_id} to ({end_pos.get('x', 0):.0f}, {end_pos.get('y', 0):.0f})")
        log = log[:20]

        metrics["drags_completed"] = metrics.get("drags_completed", 0) + 1
        metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

        return log, metrics

    raise PreventUpdate


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "lastConnection"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_connection(last_connection, log, metrics):
    """Monitor new connection events."""
    if not last_connection:
        raise PreventUpdate

    source = last_connection.get("source", "?")
    target = last_connection.get("target", "?")

    log = log or []
    log.insert(0, f"[CONNECTION] {source} -> {target}")
    log = log[:20]

    metrics["connections_made"] = metrics.get("connections_made", 0) + 1
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return log, metrics


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "selectedNodes"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_selection_change(selected_nodes, log, metrics):
    """Monitor selection changes."""
    count = len(selected_nodes) if selected_nodes else 0

    log = log or []
    log.insert(0, f"[SELECTION] {count} node(s) selected")
    log = log[:20]

    metrics["selections_made"] = metrics.get("selections_made", 0) + 1
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return log, metrics


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Output("metrics-store", "data", allow_duplicate=True),
    Input("stress-flow", "deletedNodes"),
    Input("stress-flow", "deletedEdges"),
    State("event-log-store", "data"),
    State("metrics-store", "data"),
    prevent_initial_call=True,
)
def on_delete(deleted_nodes, deleted_edges, log, metrics):
    """Monitor deletion events."""
    log = log or []

    if deleted_nodes:
        count = len(deleted_nodes)
        log.insert(0, f"[DELETE] {count} node(s) deleted")
        metrics["nodes_removed"] = metrics.get("nodes_removed", 0) + count

    if deleted_edges:
        count = len(deleted_edges)
        log.insert(0, f"[DELETE] {count} edge(s) deleted")
        metrics["edges_removed"] = metrics.get("edges_removed", 0) + count

    if not deleted_nodes and not deleted_edges:
        raise PreventUpdate

    log = log[:20]
    metrics["callbacks_fired"] = metrics.get("callbacks_fired", 0) + 1

    return log, metrics


# ============================================================================
# DISPLAY CALLBACKS
# ============================================================================

@callback(
    Output("live-metrics", "children"),
    Input("metrics-interval", "n_intervals"),
    State("metrics-store", "data"),
)
def update_live_metrics(n_intervals, metrics):
    """Update live metrics display."""
    elapsed = time.time() - metrics.get("start_time", time.time())
    callbacks_fired = metrics.get("callbacks_fired", 0)
    rate = callbacks_fired / elapsed if elapsed > 0 else 0

    return dmc.Stack([
        dmc.Group([
            dmc.Text("Elapsed:", size="sm"),
            dmc.Text(f"{elapsed:.1f}s", fw=600, size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Callbacks:", size="sm"),
            dmc.Text(f"{callbacks_fired}", fw=600, size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Rate:", size="sm"),
            dmc.Text(f"{rate:.2f}/s", fw=600, size="sm"),
        ], justify="space-between"),
    ], gap=4)


@callback(
    Output("callback-activity", "children"),
    Input("metrics-store", "data"),
)
def update_callback_activity(metrics):
    """Update callback activity display."""
    return dmc.Stack([
        dmc.Group([
            dmc.Text("Nodes Added:", size="sm"),
            dmc.Badge(str(metrics.get("nodes_added", 0)), color="green", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Nodes Removed:", size="sm"),
            dmc.Badge(str(metrics.get("nodes_removed", 0)), color="red", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Edges Added:", size="sm"),
            dmc.Badge(str(metrics.get("edges_added", 0)), color="blue", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Edges Removed:", size="sm"),
            dmc.Badge(str(metrics.get("edges_removed", 0)), color="orange", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Drags:", size="sm"),
            dmc.Badge(str(metrics.get("drags_completed", 0)), color="violet", size="sm"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Connections:", size="sm"),
            dmc.Badge(str(metrics.get("connections_made", 0)), color="cyan", size="sm"),
        ], justify="space-between"),
    ], gap=4)


@callback(
    Output("current-state", "children"),
    Input("stress-flow", "nodes"),
    Input("stress-flow", "edges"),
)
def update_current_state(nodes, edges):
    """Display current node and edge counts."""
    node_count = len(nodes) if nodes else 0
    edge_count = len(edges) if edges else 0

    return dmc.Stack([
        dmc.Group([
            dmc.Text("Total Nodes:", size="sm"),
            dmc.Text(f"{node_count}", fw=700, size="lg", c="blue"),
        ], justify="space-between"),
        dmc.Group([
            dmc.Text("Total Edges:", size="sm"),
            dmc.Text(f"{edge_count}", fw=700, size="lg", c="grape"),
        ], justify="space-between"),
    ], gap=4)


@callback(
    Output("last-events", "children"),
    Input("stress-flow", "clickedNode"),
    Input("stress-flow", "hoveredNode"),
    Input("stress-flow", "draggedNode"),
)
def update_last_events(clicked, hovered, dragged):
    """Display last event information."""
    events = []

    if clicked:
        node_id = clicked.get("id", clicked) if isinstance(clicked, dict) else clicked
        events.append(dmc.Text(f"Clicked: {node_id}", size="xs"))

    if hovered:
        node_id = hovered.get("id", hovered) if isinstance(hovered, dict) else hovered
        events.append(dmc.Text(f"Hovered: {node_id}", size="xs", c="dimmed"))

    if dragged:
        node_id = dragged.get("id", "?")
        is_dragging = dragged.get("isDragging", False)
        status = "dragging..." if is_dragging else "dropped"
        events.append(dmc.Text(f"Drag: {node_id} ({status})", size="xs"))

    if not events:
        events.append(dmc.Text("No recent events", size="xs", c="dimmed"))

    return dmc.Stack(events, gap=2)


@callback(
    Output("event-log", "children"),
    Input("event-log-store", "data"),
)
def update_event_log(log):
    """Display the event log."""
    if not log:
        return dmc.Text("No events yet...", c="dimmed", size="sm")

    return html.Div([
        html.Div(entry, style={"padding": "2px 0"}) for entry in log
    ])


@callback(
    Output("event-log-store", "data", allow_duplicate=True),
    Input("btn-clear-log", "n_clicks"),
    prevent_initial_call=True,
)
def clear_log(n_clicks):
    """Clear the event log."""
    return []


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `generate_grid_nodes` / `generate_grid_edges` — build a 50-node grid up front so the stress test starts with a non-trivial graph
- Batch buttons (`Add Batch Nodes`, `Stress Connect All`, etc.) — each dispatches one callback that mutates `nodes`/`edges` with `allow_duplicate=True` outputs so multiple buttons can target the same props
- `clickedNode`, `draggedNode`, `lastConnection`, `selectedNodes`, `deletedNodes` / `deletedEdges` — five independent listener callbacks log every interaction type into a shared event log store
- `dcc.Interval` — polls a metrics store every 500ms to compute a live callbacks-per-second rate, which is what the "stress test" is actually measuring

#### Phase 1 Features

A showcase of the React Flow 12.10.1 upgrade props: a drag threshold before a connection starts, z-index elevation for selected elements, auto-panning when tabbing between nodes, and an edge toolbar that appears on selection.



```python
# File: examples/25_phase1_features.py

"""
Example 25: Phase 1 Feature Showcase
=====================================
Demonstrates new features from React Flow 12.10.1 upgrade:
- connectionDragThreshold: drag distance before connection starts
- zIndexMode: selected elements elevate above others
- autoPanOnNodeFocus: viewport pans when tabbing to nodes
- Glass connection line: styled preview during edge creation
- Type-colored MiniMap: node types shown as distinct colors
- EdgeToolbar on ButtonEdge: toolbar appears on selected edges
"""

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

app = dash.Dash(__name__)

nodes = [
    # Input nodes (green in minimap)
    {
        "id": "source-1",
        "type": "input",
        "data": {"label": "API Source", "sublabel": "REST Endpoint"},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "source-2",
        "type": "input",
        "data": {"label": "DB Source", "sublabel": "PostgreSQL"},
        "position": {"x": 50, "y": 200},
    },

    # Default nodes (blue in minimap)
    {
        "id": "validate",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Schema Check"},
        "position": {"x": 300, "y": 50},
    },
    {
        "id": "transform",
        "type": "default",
        "data": {"label": "Transform", "sublabel": "Map & Filter"},
        "position": {"x": 300, "y": 200},
    },
    {
        "id": "merge",
        "type": "default",
        "data": {"label": "Merge", "sublabel": "Join Data"},
        "position": {"x": 550, "y": 125},
    },

    # Output nodes (purple in minimap)
    {
        "id": "output-1",
        "type": "output",
        "data": {"label": "Dashboard", "sublabel": "Visualization"},
        "position": {"x": 800, "y": 50},
    },
    {
        "id": "output-2",
        "type": "output",
        "data": {"label": "Export", "sublabel": "CSV File"},
        "position": {"x": 800, "y": 200},
    },

    # Toolbar node (amber in minimap)
    {
        "id": "config",
        "type": "toolbar",
        "data": {"label": "Config", "sublabel": "Settings"},
        "position": {"x": 550, "y": 300},
    },
]

edges = [
    # Standard edges
    {"id": "e1", "source": "source-1", "target": "validate", "type": "smoothstep", "animated": True},
    {"id": "e2", "source": "source-2", "target": "transform", "type": "smoothstep", "animated": True},
    {"id": "e3", "source": "validate", "target": "merge", "type": "smoothstep"},
    {"id": "e4", "source": "transform", "target": "merge", "type": "smoothstep"},

    # ButtonEdge with toolbar — select this edge to see the EdgeToolbar
    {
        "id": "e5",
        "source": "merge",
        "target": "output-1",
        "type": "button",
        "data": {
            "label": "Primary",
            "showButton": False,   # Hide inline delete button
            "showToolbar": True,   # EdgeToolbar appears when selected instead
        },
    },
    {
        "id": "e6",
        "source": "merge",
        "target": "output-2",
        "type": "button",
        "data": {
            "label": "Secondary",
            "showButton": False,
            "showToolbar": True,
        },
    },

    # Edge to config
    {"id": "e7", "source": "merge", "target": "config", "type": "smoothstep", "style": {"strokeDasharray": "5 5"}},
]


app.layout = html.Div([
    html.H1("Phase 1 Feature Showcase"),

    html.Div([
        html.H3("New Features Demonstrated"),
        html.Ul([
            html.Li([
                html.Strong("connectionDragThreshold=10"),
                " — Drag 10px from a handle before the connection line appears (prevents accidental connections)",
            ]),
            html.Li([
                html.Strong("zIndexMode='elevate'"),
                " — Selected nodes and their connected edges elevate above all other elements",
            ]),
            html.Li([
                html.Strong("autoPanOnNodeFocus=True"),
                " — Click inside the flow canvas first, then press Tab to cycle through nodes; viewport auto-pans to keep the focused node visible",
            ]),
            html.Li([
                html.Strong("Glass Connection Line"),
                " — Drag from any handle to see the new blue dashed connection preview with glow effect",
            ]),
            html.Li([
                html.Strong("Type-Colored MiniMap"),
                " — Check the minimap (bottom-right): green=input, blue=default, purple=output, amber=toolbar",
            ]),
            html.Li([
                html.Strong("EdgeToolbar"),
                " — Click the 'Primary' or 'Secondary' edge line (not a node) to select it — a glass toolbar with edit/delete buttons appears above the edge",
            ]),
        ]),
    ], style={"padding": "0 16px", "fontSize": "14px"}),

    dash_flows.DashFlows(
        id="feature-flow",
        nodes=nodes,
        edges=edges,
        # New Phase 1 props
        connectionDragThreshold=10,
        zIndexMode="elevate",
        autoPanOnNodeFocus=True,
        # Standard display options
        style={"height": "600px", "border": "1px solid #ddd"},
        fitView=True,
        fitViewOptions={"padding": 0.15},
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        colorScheme="default",
    ),
])


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `connectionDragThreshold=10` — the connection line only appears after dragging 10px from a handle, preventing accidental connections on a light click
- `zIndexMode="elevate"` — selected nodes and their connected edges render above everything else
- `autoPanOnNodeFocus=True` — tabbing through nodes (after clicking into the canvas) pans the viewport to keep the focused node visible
- `data.showToolbar=True` on a `type="button"` edge — replaces the inline delete button with a floating `EdgeToolbar` that appears when the edge is selected
- MiniMap node coloring is automatic by node `type` (green=input, blue=default, purple=output, amber=toolbar) — no extra prop needed

#### Accessibility

Custom ARIA labels for the diagram, minimap, and controls, per-node/per-edge `ariaLabel` overrides, and keyboard-only navigation via focusable nodes and edges.



```python
# File: examples/29_accessibility.py

"""
Example 29: Accessibility / ARIA Pass-through
==============================================
Demonstrates ARIA label customization, keyboard navigation, and focus
management for screen-reader and keyboard-only users.

Key features:
- ariaLabelConfig: custom labels for the diagram, minimap, and controls
- Per-node / per-edge ariaLabel: individual element descriptions
- nodesFocusable / edgesFocusable: Tab key navigates between elements
- disableKeyboardA11y=False: arrow-key panning and keyboard shortcuts active
"""

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

app = dash.Dash(__name__)

nodes = [
    {
        "id": "start",
        "type": "input",
        "data": {"label": "Start", "sublabel": "Entry point"},
        "position": {"x": 50, "y": 120},
        "ariaLabel": "Start process node — entry point of the workflow",
    },
    {
        "id": "validate",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Check data"},
        "position": {"x": 280, "y": 50},
        "ariaLabel": "Validation node — checks input data for correctness",
    },
    {
        "id": "process",
        "type": "default",
        "data": {"label": "Process", "sublabel": "Transform"},
        "position": {"x": 280, "y": 200},
        "ariaLabel": "Processing node — transforms validated data",
    },
    {
        "id": "end",
        "type": "output",
        "data": {"label": "Complete", "sublabel": "Output"},
        "position": {"x": 510, "y": 120},
        "ariaLabel": "Completion node — final output of the workflow",
    },
]

edges = [
    {"id": "e1", "source": "start",    "target": "validate", "ariaLabel": "Flow from start to validation"},
    {"id": "e2", "source": "start",    "target": "process",  "ariaLabel": "Flow from start to processing"},
    {"id": "e3", "source": "validate", "target": "end",      "ariaLabel": "Flow from validation to completion"},
    {"id": "e4", "source": "process",  "target": "end",      "ariaLabel": "Flow from processing to completion"},
]

aria_config = {
    "rfDiagram": "Interactive workflow diagram with 4 nodes and 4 connections",
    "miniMap":   "Minimap navigation — overview of the workflow diagram",
    "controls":  "Zoom and pan controls for the workflow diagram",
}

# Keyboard shortcut reference table
shortcuts = [
    ("Tab / Shift+Tab",  "Move focus between nodes and edges"),
    ("Enter / Space",    "Select the focused element"),
    ("Escape",           "Clear selection / close any overlay"),
    ("Arrow keys",       "Pan the viewport (when focus is on the canvas)"),
    ("+ / −",            "Zoom in / zoom out"),
    ("Delete / Backspace","Delete selected node or edge"),
]

app.layout = html.Div([
    html.H2("Accessibility / ARIA Labels"),
    html.P(
        "Tab through nodes and edges using the keyboard. "
        "Each element has an ARIA label that screen readers will announce. "
        "Inspect the DOM to see all applied aria-* attributes.",
        style={"color": "#666", "marginBottom": "12px"},
    ),

    # Keyboard shortcut reference
    html.Details([
        html.Summary("⌨  Keyboard shortcuts (click to expand)",
                     style={"cursor": "pointer", "fontWeight": "500", "fontSize": "13px", "color": "#374151"}),
        html.Table([
            html.Tbody([
                html.Tr([
                    html.Td(k, style={"padding": "3px 10px 3px 0", "fontFamily": "monospace",
                                      "fontSize": "12px", "whiteSpace": "nowrap", "color": "#1d4ed8"}),
                    html.Td(v, style={"padding": "3px 0", "fontSize": "12px", "color": "#374151"}),
                ])
                for k, v in shortcuts
            ]),
        ], style={"borderCollapse": "collapse", "marginTop": "6px"}),
    ], style={
        "background": "#f0f7ff", "border": "1px solid #bfdbfe",
        "borderRadius": "8px", "padding": "10px 14px", "marginBottom": "12px",
    }),

    dash_flows.DashFlows(
        id="accessible-flow",
        nodes=nodes,
        edges=edges,
        ariaLabelConfig=aria_config,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        disableKeyboardA11y=False,
        nodesFocusable=True,
        edgesFocusable=True,
        style={"height": "420px"},
    ),

    # Live info panel
    html.Div(id="a11y-info", style={
        "marginTop": "12px", "padding": "12px 14px",
        "background": "#f9fafb", "border": "1px solid #e5e7eb",
        "borderRadius": "8px", "minHeight": "56px",
    }),
], style={"padding": "20px"})


@callback(
    Output("a11y-info", "children"),
    Input("accessible-flow", "clickedNode"),
    Input("accessible-flow", "clickedEdge"),
    prevent_initial_call=True,
)
def show_element_info(clicked_node, clicked_edge):
    from dash import ctx
    if ctx.triggered_id == "accessible-flow" and clicked_node:
        nid = clicked_node["id"]
        label = clicked_node["data"].get("label", nid)
        # Find the matching ariaLabel
        aria = next((n["ariaLabel"] for n in nodes if n["id"] == nid), "No ARIA label set")
        return html.Div([
            html.Strong(f"Node clicked: {label}"),
            html.Br(),
            html.Span(f"ARIA label: \"{aria}\"",
                      style={"fontSize": "12px", "color": "#6b7280", "fontStyle": "italic"}),
            html.Br(),
            html.Span("Tip: Tab to next node, Delete/Backspace to remove selection",
                      style={"fontSize": "11px", "color": "#9ca3af"}),
        ])
    if ctx.triggered_id == "accessible-flow" and clicked_edge:
        eid = clicked_edge["id"]
        aria = next((e["ariaLabel"] for e in edges if e["id"] == eid), "No ARIA label set")
        return html.Div([
            html.Strong(f"Edge clicked: {eid}"),
            html.Br(),
            html.Span(f"ARIA label: \"{aria}\"",
                      style={"fontSize": "12px", "color": "#6b7280", "fontStyle": "italic"}),
        ])
    return html.Span("Click a node or edge to see its ARIA label.",
                     style={"color": "#9ca3af", "fontSize": "13px"})


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `ariaLabelConfig` — supplies custom labels for the `rfDiagram`, `miniMap`, and `controls` regions, read by screen readers
- `ariaLabel` on individual node/edge dicts — gives each element its own accessible description, shown here in the info panel below the canvas
- `nodesFocusable` / `edgesFocusable` — makes every node and edge reachable via Tab / Shift+Tab
- `disableKeyboardA11y=False` — keeps arrow-key panning, Enter/Space selection, and Delete/Backspace shortcuts active
- `clickedNode` / `clickedEdge` — read in a callback to display which element was activated and its ARIA label

#### Resize Constraints

Min/max width and height bounds, plus locked aspect ratios, applied to both `ResizableNode` and `GroupNode`.



```python
# File: examples/30_resize_constraints.py

"""
Example 30: Node Resize Constraints
====================================
Demonstrates resize constraints on ResizableNode and GroupNode:
- keepAspectRatio: Lock aspect ratio during resize
- maxWidth / maxHeight: Maximum size limits
"""

import dash
from dash import html
import dash_flows

app = dash.Dash(__name__)

nodes = [
    # Resizable node with aspect ratio lock
    {
        "id": "aspect-locked",
        "type": "resizable",
        "data": {
            "label": "Aspect Ratio Locked (2:1)",
            "handles": [
                {"id": "source-right", "type": "source", "position": "right"},
                {"id": "target-left", "type": "target", "position": "left"},
            ],
            "keepAspectRatio": True,
            "initialWidth": 300,
            "initialHeight": 150,
            "minWidth": 200,
            "minHeight": 100,
        },
        "position": {"x": 50, "y": 50},
        "style": {"width": 300, "height": 150},
    },
    # Resizable node with max dimensions
    {
        "id": "max-constrained",
        "type": "resizable",
        "data": {
            "label": "Max 400x200",
            "handles": [
                {"id": "source-right", "type": "source", "position": "right"},
                {"id": "target-left", "type": "target", "position": "left"},
            ],
            "maxWidth": 400,
            "maxHeight": 200,
            "minWidth": 150,
            "minHeight": 80,
        },
        "position": {"x": 50, "y": 280},
        "style": {"width": 250, "height": 120},
    },
    # Group node with resize constraints
    {
        "id": "constrained-group",
        "type": "group",
        "data": {
            "label": "Constrained Group (max 500x400)",
            "maxWidth": 500,
            "maxHeight": 400,
            "minWidth": 200,
            "minHeight": 150,
        },
        "position": {"x": 450, "y": 50},
        "style": {"width": 350, "height": 300},
    },
    # Child inside group
    {
        "id": "child-1",
        "type": "default",
        "data": {"label": "Child Node A"},
        "position": {"x": 30, "y": 50},
        "parentId": "constrained-group",
        "extent": "parent",
    },
    {
        "id": "child-2",
        "type": "default",
        "data": {"label": "Child Node B"},
        "position": {"x": 30, "y": 150},
        "parentId": "constrained-group",
        "extent": "parent",
    },
    # Group with aspect ratio lock
    {
        "id": "aspect-group",
        "type": "group",
        "data": {
            "label": "Aspect Locked Group",
            "keepAspectRatio": True,
            "minWidth": 150,
            "minHeight": 150,
        },
        "position": {"x": 450, "y": 380},
        "style": {"width": 250, "height": 250},
    },
]

edges = [
    {"id": "e1", "source": "aspect-locked", "target": "max-constrained"},
    {"id": "e2", "source": "child-1", "target": "child-2"},
]

app.layout = html.Div([
    html.H2("Node Resize Constraints"),

    # Constraint summary
    html.Div([
        html.Strong("How to resize: ", style={"fontSize": "13px"}),
        html.Span("Click a node to select it, then drag the blue corner / edge handles that appear.",
                  style={"fontSize": "13px", "color": "#555"}),
        html.Br(),
        html.Div([
            html.Span("■ Top-left ResizableNode",  style={"color": "#3b82f6", "fontWeight": "600", "fontSize": "12px"}),
            html.Span(" — aspect ratio locked 2:1, min 200×100",           style={"fontSize": "12px", "color": "#555", "marginRight": "18px"}),
            html.Span("■ Bottom-left ResizableNode", style={"color": "#8b5cf6", "fontWeight": "600", "fontSize": "12px"}),
            html.Span(" — max 400×200, min 150×80",                         style={"fontSize": "12px", "color": "#555"}),
        ], style={"marginTop": "4px"}),
        html.Div([
            html.Span("■ Right GroupNode",           style={"color": "#10b981", "fontWeight": "600", "fontSize": "12px"}),
            html.Span(" — max 500×400, min 200×150",                         style={"fontSize": "12px", "color": "#555", "marginRight": "18px"}),
            html.Span("■ Bottom-right GroupNode",    style={"color": "#f59e0b", "fontWeight": "600", "fontSize": "12px"}),
            html.Span(" — aspect ratio locked, min 150×150",                 style={"fontSize": "12px", "color": "#555"}),
        ], style={"marginTop": "2px"}),
    ], style={
        "background": "#f9fafb", "border": "1px solid #e5e7eb",
        "borderRadius": "8px", "padding": "10px 14px", "marginBottom": "12px",
    }),

    dash_flows.DashFlows(
        id="resize-flow",
        nodes=nodes,
        edges=edges,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        style={"height": "700px"},
    ),
], style={"padding": "20px"})

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `data.minWidth` / `data.maxWidth` / `data.minHeight` / `data.maxHeight` — clamp how far a `resizable` node or `group` node can be dragged in each dimension
- `data.keepAspectRatio=True` — locks the width:height ratio while resizing, regardless of which handle is dragged
- `data.handles` — customizes which side(s) of a `resizable` node expose source/target connection points
- Group resize constraints apply to the container only — child nodes with `parentId` / `extent: "parent"` stay clipped to whatever size the group currently is

#### Undo / Redo

History tracking for node moves, connections, and deletions, with dedicated Undo/Redo buttons and a live history counter.



```python
# File: examples/32_undo_redo.py

"""
Example 32: Undo/Redo System
=============================
Demonstrates the undo/redo history system. Move nodes, create connections,
delete elements — then undo and redo your changes.

Features demonstrated:
- Node movement undo/redo (drag a node, then undo)
- Node/edge deletion undo/redo (select + Delete key, or use button)
- Connection creation undo (draw an edge between nodes)
- History counter shows pending undo/redo steps

Uses experimental_useOnNodesChangeMiddleware and
experimental_useOnEdgesChangeMiddleware from React Flow 12.10.1.
"""

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

app = dash.Dash(__name__)

initial_nodes = [
    {"id": "a", "type": "input",   "data": {"label": "Node A", "sublabel": "Source"},   "position": {"x": 50,  "y": 150}},
    {"id": "b", "type": "default", "data": {"label": "Node B", "sublabel": "Process 1"}, "position": {"x": 280, "y": 50}},
    {"id": "c", "type": "default", "data": {"label": "Node C", "sublabel": "Process 2"}, "position": {"x": 280, "y": 250}},
    {"id": "d", "type": "output",  "data": {"label": "Node D", "sublabel": "Output"},   "position": {"x": 510, "y": 150}},
]

initial_edges = [
    {"id": "e-ab", "source": "a", "target": "b"},
    {"id": "e-ac", "source": "a", "target": "c"},
    {"id": "e-bd", "source": "b", "target": "d"},
]

btn_style = {
    "padding": "8px 16px",
    "border": "1px solid #ddd",
    "borderRadius": "6px",
    "cursor": "pointer",
    "fontSize": "13px",
    "fontWeight": "500",
    "minWidth": "80px",
}

app.layout = html.Div([
    html.H2("Undo/Redo System"),

    # Instruction panel
    html.Div([
        html.Div([
            html.Strong("Try these actions:"),
            html.Ul([
                html.Li("Drag a node to a new position → then Undo"),
                html.Li("Select a node (click) → press Delete or Backspace → then Undo"),
                html.Li("Draw a new edge by dragging from a handle → then Undo"),
                html.Li("Use the Delete Selected button below to remove selected nodes"),
            ], style={"margin": "6px 0 0 0", "paddingLeft": "18px", "fontSize": "13px", "color": "#555"}),
        ]),
    ], style={
        "background": "#f0f7ff",
        "border": "1px solid #bfdbfe",
        "borderRadius": "8px",
        "padding": "10px 14px",
        "marginBottom": "10px",
        "fontSize": "13px",
    }),

    # Controls row
    html.Div([
        html.Button(
            "⟲ Undo", id="btn-undo",
            style={**btn_style, "background": "#fee2e2", "borderColor": "#fca5a5"},
            disabled=True,
        ),
        html.Button(
            "⟳ Redo", id="btn-redo",
            style={**btn_style, "background": "#dbeafe", "borderColor": "#93c5fd"},
            disabled=True,
        ),
        html.Button(
            "🗑 Delete Selected", id="btn-delete",
            style={**btn_style, "background": "#fef9c3", "borderColor": "#fde047"},
        ),
        html.Span(id="history-info", style={
            "padding": "8px 14px",
            "background": "#f0f0f0",
            "borderRadius": "6px",
            "fontSize": "12px",
            "color": "#555",
            "marginLeft": "4px",
        }),
    ], style={"display": "flex", "gap": "8px", "alignItems": "center", "marginBottom": "10px"}),

    # Store to hold selected node IDs for delete action
    dcc.Store(id="selected-ids-store"),

    dash_flows.DashFlows(
        id="undo-redo-flow",
        nodes=initial_nodes,
        edges=initial_edges,
        fitView=True,
        enableUndoRedo=True,
        undoRedoMaxHistory=50,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        style={"height": "500px"},
    ),

    html.Div(id="action-log", style={
        "marginTop": "10px",
        "padding": "8px 12px",
        "background": "#fafafa",
        "border": "1px solid #e5e7eb",
        "borderRadius": "6px",
        "fontSize": "12px",
        "color": "#6b7280",
        "minHeight": "32px",
    }),
], style={"padding": "20px"})


@callback(
    Output("undo-redo-flow", "undoRedoAction"),
    Input("btn-undo", "n_clicks"),
    Input("btn-redo", "n_clicks"),
    prevent_initial_call=True,
)
def handle_undo_redo(undo_clicks, redo_clicks):
    from dash import ctx
    if ctx.triggered_id == "btn-undo":
        return {"action": "undo"}
    elif ctx.triggered_id == "btn-redo":
        return {"action": "redo"}
    return dash.no_update


@callback(
    Output("btn-undo", "disabled"),
    Output("btn-redo", "disabled"),
    Output("history-info", "children"),
    Input("undo-redo-flow", "undoRedoState"),
)
def update_buttons(state):
    if not state:
        return True, True, "History: 0 undo / 0 redo"
    undo_count = state.get("undoCount", 0)
    redo_count = state.get("redoCount", 0)
    label = f"History: {undo_count} undo / {redo_count} redo"
    return (
        not state.get("canUndo", False),
        not state.get("canRedo", False),
        label,
    )


@callback(
    Output("selected-ids-store", "data"),
    Input("undo-redo-flow", "selectedNodes"),
    Input("undo-redo-flow", "selectedEdges"),
)
def track_selected(sel_nodes, sel_edges):
    # selectedNodes / selectedEdges are already arrays of string IDs
    return {"nodes": sel_nodes or [], "edges": sel_edges or []}


@callback(
    Output("undo-redo-flow", "deleteElementsAction"),
    Output("action-log", "children"),
    Input("btn-delete", "n_clicks"),
    State("selected-ids-store", "data"),
    prevent_initial_call=True,
)
def delete_selected(n_clicks, selected):
    if not selected:
        return dash.no_update, "Nothing selected to delete."

    node_ids = selected.get("nodes", [])
    edge_ids = selected.get("edges", [])

    if not node_ids and not edge_ids:
        return dash.no_update, "Nothing selected to delete."

    parts = []
    if node_ids:
        parts.append(f"{len(node_ids)} node(s)")
    if edge_ids:
        parts.append(f"{len(edge_ids)} edge(s)")
    msg = f"Deleted {' and '.join(parts)}. Press ⟲ Undo to restore."

    # Route through deleteElementsAction so React Flow's normal deletion
    # flow fires — the undo/redo middleware can then capture the snapshot.
    return {"nodeIds": node_ids, "edgeIds": edge_ids}, msg


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `enableUndoRedo=True` and `undoRedoMaxHistory=50` — turns on history tracking and caps how many snapshots are kept
- `undoRedoAction` — set to `{"action": "undo"}` or `{"action": "redo"}` from the button callbacks to step through history
- `undoRedoState` — an output prop with `canUndo` / `canRedo` / `undoCount` / `redoCount`, used here to enable/disable the buttons and render the history badge
- `deleteElementsAction` — routes the "Delete Selected" button through React Flow's normal deletion pipeline (rather than mutating `nodes`/`edges` directly) so the undo/redo middleware can capture the change
- `selectedNodes` / `selectedEdges` — tracked in a store so the delete button knows what to remove

#### Computing Flows

Topological sort and value propagation across a small pipeline graph: input nodes hold values, operation nodes combine them, and an output node shows the final result.



```python
# File: examples/33_computing_flows.py

"""
Example 33: Computing Flows
============================
Demonstrates graph traversal and computation propagation.
Input nodes have numbers, operation nodes perform math,
output nodes show results. Click "Compute" to propagate values.

JS does topological sort only — Python handles all business logic.

Pipeline: (A + B) × C = (10 + 5) × 3 = 45
"""

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

app = dash.Dash(__name__)

initial_nodes = [
    {
        "id": "input-a",
        "type": "input",
        "data": {"label": "Input A", "sublabel": "Value: 10", "computedValue": 10},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "input-b",
        "type": "input",
        "data": {"label": "Input B", "sublabel": "Value: 5", "computedValue": 5},
        "position": {"x": 50, "y": 200},
    },
    {
        "id": "input-c",
        "type": "input",
        "data": {"label": "Input C", "sublabel": "Value: 3", "computedValue": 3},
        "position": {"x": 50, "y": 350},
    },
    {
        "id": "add",
        "type": "default",
        "data": {"label": "Add (+)", "sublabel": "A + B", "operation": "add", "computedValue": None},
        "position": {"x": 300, "y": 100},
    },
    {
        "id": "multiply",
        "type": "default",
        "data": {"label": "Multiply (×)", "sublabel": "result × C", "operation": "multiply", "computedValue": None},
        "position": {"x": 550, "y": 200},
    },
    {
        "id": "output",
        "type": "output",
        "data": {"label": "Result", "sublabel": "Waiting...", "computedValue": None},
        "position": {"x": 800, "y": 200},
    },
]

initial_edges = [
    {"id": "e1", "source": "input-a", "target": "add"},
    {"id": "e2", "source": "input-b", "target": "add"},
    {"id": "e3", "source": "add", "target": "multiply"},
    {"id": "e4", "source": "input-c", "target": "multiply"},
    {"id": "e5", "source": "multiply", "target": "output"},
]

btn_style = {
    "padding": "10px 24px",
    "border": "none",
    "borderRadius": "8px",
    "background": "linear-gradient(135deg, #3b82f6, #8b5cf6)",
    "color": "white",
    "cursor": "pointer",
    "fontSize": "14px",
    "fontWeight": "600",
}

reset_btn_style = {
    "padding": "10px 18px",
    "border": "1px solid #ddd",
    "borderRadius": "8px",
    "background": "#f9fafb",
    "color": "#374151",
    "cursor": "pointer",
    "fontSize": "14px",
}

app.layout = html.Div([
    html.H2("Computing Flows"),
    html.P(
        "Pipeline: (A + B) × C → Result.  Click ⚡ Compute to propagate values through the graph.",
        style={"color": "#666"},
    ),

    html.Div([
        html.Button("⚡ Compute", id="btn-compute", style=btn_style),
        html.Button("↺ Reset", id="btn-reset", style=reset_btn_style),
        html.Span(id="compute-status", style={
            "padding": "8px 16px", "background": "#f0f0f0",
            "borderRadius": "6px", "fontSize": "13px",
        }),
    ], style={"display": "flex", "gap": "10px", "alignItems": "center", "marginBottom": "10px"}),

    dash_flows.DashFlows(
        id="compute-flow",
        nodes=initial_nodes,
        edges=initial_edges,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        smartHandles=True,
        style={"height": "500px"},
    ),
], style={"padding": "20px"})


@callback(
    Output("compute-flow", "computeAction"),
    Input("btn-compute", "n_clicks"),
    prevent_initial_call=True,
)
def trigger_compute(n):
    return {"action": "compute"}


@callback(
    Output("compute-flow", "nodes"),
    Output("compute-status", "children"),
    Input("compute-flow", "computeResult"),
    State("compute-flow", "nodes"),
    prevent_initial_call=True,
)
def process_computation(result, nodes):
    if not result:
        return dash.no_update, dash.no_update

    traversal = result.get("traversalOrder", [])
    node_inputs_meta = result.get("nodeInputs", {})

    node_map = {n["id"]: n for n in nodes}

    # Seed computed_values from nodes that already have a value (source nodes).
    # We maintain our own dict so intermediate results propagate correctly
    # through the topological order — the JS snapshot only knows values that
    # existed BEFORE this compute call.
    computed_values = {}
    for n in nodes:
        val = n["data"].get("computedValue")
        if val is not None:
            computed_values[n["id"]] = val

    steps = []

    for node_id in traversal:
        node = node_map.get(node_id)
        if not node:
            continue

        meta = node_inputs_meta.get(node_id, {})
        incoming = meta.get("inputs", [])  # [{nodeId, value, data}, ...]
        operation = node["data"].get("operation")

        if not incoming:
            # Source node — keep its seed value, nothing to compute
            continue

        # Use our locally propagated computed_values for inputs, not the
        # stale JS snapshot values (which are None for intermediate nodes).
        input_values = [
            computed_values[inp["nodeId"]]
            for inp in incoming
            if inp["nodeId"] in computed_values
        ]

        if not input_values:
            continue

        if operation == "add":
            computed = sum(input_values)
            steps.append(f"{node_id}: {' + '.join(str(v) for v in input_values)} = {computed}")
        elif operation == "multiply":
            computed = 1
            for v in input_values:
                computed *= v
            steps.append(f"{node_id}: {' × '.join(str(v) for v in input_values)} = {computed}")
        else:
            # Output node or unknown: pass through
            computed = sum(input_values)
            steps.append(f"{node_id}: result = {computed}")

        computed_values[node_id] = computed
        node["data"] = dict(node["data"])
        node["data"]["computedValue"] = computed
        node["data"]["sublabel"] = f"= {computed}"

    updated_nodes = list(node_map.values())
    final_value = computed_values.get("output", "?")
    status = f"✓ Result = {final_value}   ({' → '.join(steps)})"

    return updated_nodes, status


@callback(
    Output("compute-flow", "nodes", allow_duplicate=True),
    Output("compute-status", "children", allow_duplicate=True),
    Input("btn-reset", "n_clicks"),
    prevent_initial_call=True,
)
def reset_flow(n):
    import copy
    return copy.deepcopy(initial_nodes), "Reset — click ⚡ Compute to run again."


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `computeAction={"action": "compute"}` — triggers a client-side topological sort of the graph
- `computeResult` — an output prop with `traversalOrder` (node IDs in dependency order) and `nodeInputs` (each node's incoming values); JS only does the sort, Python does all the math
- The callback walks `traversalOrder`, applies each node's `data.operation` (`add` / `multiply`) to its inputs, and writes the running value back into `data.computedValue` / `data.sublabel`
- `smartHandles=True` — lets edges route to whichever side of a node is closest, useful once the pipeline fans out
- The "Reset" button restores `initial_nodes` via `copy.deepcopy` so a fresh compute run starts from the original seed values

#### Viewport Portal

Floating annotations rendered at specific flow coordinates that pan and zoom together with the canvas, with a small editor to add, edit, and remove them.



```python
# File: examples/34_viewport_portal.py

"""
Example 34: ViewportPortal — Floating Annotations
===================================================
Demonstrates floating annotations rendered at specific flow coordinates
via ViewportPortal. Overlays move with pan/zoom.

Features:
- Add / remove annotations
- Select an annotation from the list and edit its x, y position and content
- Changes update immediately in the flow
"""

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

app = dash.Dash(__name__)

nodes = [
    {"id": "server", "type": "default", "data": {"label": "API Server",  "sublabel": "Port 8080"},  "position": {"x": 100, "y": 100}},
    {"id": "db",     "type": "default", "data": {"label": "Database",    "sublabel": "PostgreSQL"}, "position": {"x": 400, "y": 100}},
    {"id": "cache",  "type": "default", "data": {"label": "Cache",       "sublabel": "Redis"},      "position": {"x": 400, "y": 280}},
    {"id": "client", "type": "input",   "data": {"label": "Client App"},                            "position": {"x": -150, "y": 100}},
    {"id": "cdn",    "type": "output",  "data": {"label": "CDN"},                                   "position": {"x": 700, "y": 100}},
]

edges = [
    {"id": "e1", "source": "client", "target": "server"},
    {"id": "e2", "source": "server", "target": "db"},
    {"id": "e3", "source": "server", "target": "cache"},
    {"id": "e4", "source": "db",     "target": "cdn"},
]

initial_overlays = [
    {
        "x": -150, "y": 58,
        "content": "🌐 External Traffic",
        "style": {
            "background": "rgba(59,130,246,0.1)", "border": "1px dashed rgba(59,130,246,0.4)",
            "padding": "4px 10px", "borderRadius": "6px",
            "fontSize": "11px", "color": "#3b82f6", "fontWeight": "600", "whiteSpace": "nowrap",
        },
    },
    {
        "x": 200, "y": 68,
        "content": "← REST API →",
        "style": {
            "fontSize": "10px", "color": "#888", "fontStyle": "italic", "whiteSpace": "nowrap",
        },
    },
    {
        "x": 380, "y": 200,
        "content": "⚡ Hot path",
        "style": {
            "background": "rgba(245,158,11,0.15)", "border": "1px solid rgba(245,158,11,0.3)",
            "padding": "3px 8px", "borderRadius": "4px",
            "fontSize": "10px", "color": "#d97706", "fontWeight": "500", "whiteSpace": "nowrap",
        },
    },
    {
        "x": 100, "y": -2,
        "content": "── Internal Network ──────────────────────",
        "style": {
            "fontSize": "10px", "color": "#aaa", "letterSpacing": "1px", "whiteSpace": "nowrap",
        },
    },
]

def _build_options(overlays):
    return [
        {"label": f"[{i}] {o['content'][:28]}{'…' if len(o['content']) > 28 else ''}",
         "value": i}
        for i, o in enumerate(overlays)
    ]


# Shared button style
_btn = {
    "padding": "7px 14px", "border": "1px solid #ddd",
    "borderRadius": "6px", "background": "#fff", "cursor": "pointer", "fontSize": "13px",
}

app.layout = html.Div([
    html.H2("ViewportPortal — Floating Annotations"),
    html.P(
        "Annotations are anchored to flow coordinates and move with pan/zoom. "
        "Select one from the list below to edit its position or content.",
        style={"color": "#666"},
    ),

    # Top controls
    html.Div([
        html.Button("＋ Add Annotation", id="btn-add-overlay",   style=_btn),
        html.Button("✕ Clear All",        id="btn-clear-overlays", style=_btn),
    ], style={"display": "flex", "gap": "8px", "marginBottom": "10px"}),

    # Flow
    dash_flows.DashFlows(
        id="portal-flow",
        nodes=nodes,
        edges=edges,
        viewportOverlays=initial_overlays,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        smartHandles=True,
        style={"height": "460px"},
    ),

    # Editor panel
    html.Div([
        html.H4("Edit Annotations", style={"margin": "0 0 10px 0", "fontSize": "14px"}),
        html.Div(style={"display": "flex", "gap": "16px", "alignItems": "flex-start"}, children=[

            # Annotation list (left side)
            html.Div([
                html.Div("Select:", style={"fontSize": "12px", "color": "#888", "marginBottom": "4px"}),
                dcc.RadioItems(
                    id="annotation-selector",
                    options=_build_options(initial_overlays),
                    value=None,
                    labelStyle={"display": "block", "fontSize": "13px", "padding": "3px 0", "cursor": "pointer"},
                ),
            ], style={"minWidth": "220px"}),

            # Edit form (right side)
            html.Div(id="edit-form", style={"flex": "1"}, children=[
                html.P("Select an annotation to edit it.", style={"color": "#aaa", "fontSize": "13px"}),
            ]),
        ]),
    ], style={
        "marginTop": "14px", "padding": "14px",
        "border": "1px solid #e5e7eb", "borderRadius": "8px",
        "background": "#fafafa",
    }),

    # Hidden stores
    dcc.Store(id="overlays-store", data=initial_overlays),
], style={"padding": "20px"})


# ── Build / update the annotation list & sync overlay store ──────────────────

@callback(
    Output("portal-flow", "viewportOverlays"),
    Output("overlays-store", "data"),
    Output("annotation-selector", "options"),
    Input("btn-add-overlay",   "n_clicks"),
    Input("btn-clear-overlays","n_clicks"),
    State("overlays-store", "data"),
    prevent_initial_call=True,
)
def add_or_clear(add_clicks, clear_clicks, current):
    from dash import ctx
    import random

    if ctx.triggered_id == "btn-clear-overlays":
        return [], [], []

    current = current or []
    idx = len(current) + 1
    new_overlay = {
        "x": random.randint(-100, 500),
        "y": random.randint(-50, 320),
        "content": f"📌 Note #{idx}",
        "style": {
            "background": "rgba(139,92,246,0.1)", "border": "1px solid rgba(139,92,246,0.3)",
            "padding": "4px 10px", "borderRadius": "6px",
            "fontSize": "11px", "color": "#7c3aed", "fontWeight": "500", "whiteSpace": "nowrap",
        },
    }
    updated = current + [new_overlay]
    opts = _build_options(updated)
    return updated, updated, opts


# ── Populate the edit form when an annotation is selected ────────────────────

@callback(
    Output("edit-form", "children"),
    Input("annotation-selector", "value"),
    State("overlays-store", "data"),
)
def show_edit_form(selected_idx, overlays):
    if selected_idx is None or not overlays:
        return html.P("Select an annotation to edit it.", style={"color": "#aaa", "fontSize": "13px"})

    o = overlays[selected_idx]
    field = {"width": "80px", "padding": "5px 8px", "border": "1px solid #ddd",
             "borderRadius": "5px", "fontSize": "13px"}
    content_field = {**field, "width": "260px"}

    return html.Div([
        html.Div([
            html.Label("X position:", style={"fontSize": "12px", "color": "#666", "marginRight": "6px"}),
            dcc.Input(id="edit-x",       type="number", value=o["x"],       style=field, debounce=True),
            html.Label("Y position:", style={"fontSize": "12px", "color": "#666", "margin": "0 6px 0 14px"}),
            dcc.Input(id="edit-y",       type="number", value=o["y"],       style=field, debounce=True),
        ], style={"display": "flex", "alignItems": "center", "marginBottom": "8px"}),
        html.Div([
            html.Label("Content:", style={"fontSize": "12px", "color": "#666", "marginRight": "6px"}),
            dcc.Input(id="edit-content", type="text",   value=o["content"], style=content_field, debounce=True),
        ], style={"display": "flex", "alignItems": "center", "marginBottom": "8px"}),
        html.Button("💾 Apply", id="btn-apply-edit", style={
            "padding": "7px 18px", "border": "none", "borderRadius": "6px",
            "background": "#3b82f6", "color": "white", "cursor": "pointer", "fontSize": "13px",
        }),
        html.Div(id="edit-feedback", style={"marginTop": "6px", "fontSize": "12px", "color": "#16a34a"}),
    ])


# Placeholder outputs so Dash doesn't complain about missing IDs on initial load
app.layout.children.append(html.Div([
    dcc.Input(id="edit-x",       style={"display": "none"}),
    dcc.Input(id="edit-y",       style={"display": "none"}),
    dcc.Input(id="edit-content", style={"display": "none"}),
    html.Button(id="btn-apply-edit", style={"display": "none"}),
    html.Div(id="edit-feedback"),
], id="_hidden-edit-ids", style={"display": "none"}))


# ── Apply edits ───────────────────────────────────────────────────────────────

@callback(
    Output("portal-flow",          "viewportOverlays", allow_duplicate=True),
    Output("overlays-store",       "data",             allow_duplicate=True),
    Output("annotation-selector",  "options",          allow_duplicate=True),
    Output("edit-feedback",        "children"),
    Input("btn-apply-edit",        "n_clicks"),
    State("annotation-selector",   "value"),
    State("edit-x",                "value"),
    State("edit-y",                "value"),
    State("edit-content",          "value"),
    State("overlays-store",        "data"),
    prevent_initial_call=True,
)
def apply_edit(n, selected_idx, x, y, content, overlays):
    if selected_idx is None or not overlays:
        return dash.no_update, dash.no_update, dash.no_update, "Nothing to update."

    updated = [dict(o) for o in overlays]
    updated[selected_idx] = {
        **updated[selected_idx],
        "x": x if x is not None else updated[selected_idx]["x"],
        "y": y if y is not None else updated[selected_idx]["y"],
        "content": content if content is not None else updated[selected_idx]["content"],
    }
    opts = _build_options(updated)
    return updated, updated, opts, f"✓ Annotation [{selected_idx}] updated."


# ── Sync selector options when store resets to initial overlays ──────────────

@callback(
    Output("annotation-selector", "options", allow_duplicate=True),
    Input("overlays-store", "data"),
    prevent_initial_call=True,
)
def sync_options(overlays):
    return _build_options(overlays or [])


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `viewportOverlays` — a list of `{x, y, content, style}` dicts rendered via `ViewportPortal`, anchored to flow coordinates rather than screen pixels
- Adding an annotation appends a new overlay dict with randomized coordinates and re-renders the list
- Selecting an annotation from the `RadioItems` list populates an inline edit form (`x`, `y`, `content` inputs) sourced from a `dcc.Store` holding the current overlay list
- Hidden placeholder inputs mirror the edit-form IDs so the "Apply" callback's `State` references resolve even before an annotation has ever been selected
- Because overlays are plain data, they can be generated, persisted, or restored exactly like nodes and edges

#### Sub-flows

Collapsible group nodes: double-click a group (or use the toggle buttons) to collapse it to a compact box and hide its children, while edges to/from the group stay connected.



```python
# File: examples/35_subflows.py

"""
Example 35: Sub-flows (Collapsible Groups)
===========================================
Demonstrates expandable/collapsible group nodes.

Architecture pattern for React Flow sub-flows:
- Edges that cross group boundaries connect to the GROUP node, not to children.
- Edges inside a group connect child-to-child (these are intra-group and safe).
- When a group collapses: children + their internal edges are hidden; the group
  shrinks to a compact box; external edges remain connected at the group level.

Double-click a group or use the buttons to toggle collapse/expand.
"""

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

app = dash.Dash(__name__)

# ── Initial graph ─────────────────────────────────────────────────────────────
#
#   source ──► [group-ingest: parse ──► validate] ──► [group-process: transform ──► enrich] ──► sink
#
# External edges connect to GROUP nodes (not directly to children).
# Internal edges connect children within the same group (safe pattern).

initial_nodes = [
    # External nodes
    {
        "id": "source",
        "type": "input",
        "data": {"label": "Data Source"},
        "position": {"x": 50, "y": 190},
    },

    # ── Ingestion group ────────────────────────────────────────────────────────
    {
        "id": "group-ingest",
        "type": "group",
        "data": {
            "label": "Ingestion Pipeline",
            "collapsedWidth": 200,
            "collapsedHeight": 52,
        },
        "position": {"x": 260, "y": 60},
        "style": {"width": 280, "height": 270},
    },
    {
        "id": "parse",
        "type": "default",
        "data": {"label": "Parse", "sublabel": "Deserialize"},
        "position": {"x": 50, "y": 55},
        "parentId": "group-ingest",
        "extent": "parent",
    },
    {
        "id": "validate",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Schema check"},
        "position": {"x": 50, "y": 170},
        "parentId": "group-ingest",
        "extent": "parent",
    },

    # ── Processing group ───────────────────────────────────────────────────────
    {
        "id": "group-process",
        "type": "group",
        "data": {
            "label": "Processing",
            "collapsedWidth": 200,
            "collapsedHeight": 52,
        },
        "position": {"x": 680, "y": 60},
        "style": {"width": 280, "height": 270},
    },
    {
        "id": "transform",
        "type": "default",
        "data": {"label": "Transform", "sublabel": "Normalize"},
        "position": {"x": 50, "y": 55},
        "parentId": "group-process",
        "extent": "parent",
    },
    {
        "id": "enrich",
        "type": "default",
        "data": {"label": "Enrich", "sublabel": "Add metadata"},
        "position": {"x": 50, "y": 170},
        "parentId": "group-process",
        "extent": "parent",
    },

    # External nodes
    {
        "id": "sink",
        "type": "output",
        "data": {"label": "Data Sink"},
        "position": {"x": 1100, "y": 190},
    },
]

initial_edges = [
    # External → Group  (NOT external → child inside group — avoids handle timing issues)
    {"id": "e-src-ingest",    "source": "source",        "target": "group-ingest",  "animated": True},

    # Intra-group edges (child → child within the SAME group — always safe)
    {"id": "e-parse-validate",    "source": "parse",     "target": "validate"},
    {"id": "e-transform-enrich",  "source": "transform", "target": "enrich"},

    # Group → Group  (cross-group at group level — safe)
    {"id": "e-ingest-process", "source": "group-ingest", "target": "group-process", "animated": True},

    # Group → External
    {"id": "e-process-sink",   "source": "group-process", "target": "sink",         "animated": True},
]

# ── Layout ────────────────────────────────────────────────────────────────────
btn_style = {
    "padding": "8px 16px",
    "border": "1px solid #ddd",
    "borderRadius": "6px",
    "background": "#fff",
    "cursor": "pointer",
    "fontSize": "13px",
}

app.layout = html.Div([
    html.H2("Sub-flows — Collapsible Groups"),

    html.Div([
        html.Strong("Pattern: ", style={"fontSize": "13px"}),
        html.Span(
            "External edges connect to the group container. "
            "Internal edges connect children within the same group. "
            "Collapsing hides children and shrinks the group; external connections stay intact.",
            style={"fontSize": "13px", "color": "#555"},
        ),
    ], style={
        "background": "#f0f7ff", "border": "1px solid #bfdbfe",
        "borderRadius": "8px", "padding": "10px 14px", "marginBottom": "10px",
    }),

    html.Div([
        html.Button("Toggle Ingestion",  id="btn-toggle-ingest",  style=btn_style),
        html.Button("Toggle Processing", id="btn-toggle-process", style=btn_style),
        html.Button("Expand All",        id="btn-expand-all",     style=btn_style),
        html.Span(id="collapse-info", style={
            "padding": "8px 16px", "background": "#f0f0f0",
            "borderRadius": "6px", "fontSize": "12px", "color": "#666",
        }),
    ], style={"display": "flex", "gap": "8px", "alignItems": "center", "marginBottom": "10px"}),

    dash_flows.DashFlows(
        id="subflow",
        nodes=initial_nodes,
        edges=initial_edges,
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        style={"height": "520px"},
    ),
], style={"padding": "20px"})


# ── Callbacks ─────────────────────────────────────────────────────────────────

@callback(
    Output("subflow", "toggleCollapseNode"),
    Input("btn-toggle-ingest",  "n_clicks"),
    Input("btn-toggle-process", "n_clicks"),
    Input("subflow",            "doubleClickedNode"),
    prevent_initial_call=True,
)
def toggle_group(ingest, process, dbl_clicked):
    from dash import ctx
    if ctx.triggered_id == "btn-toggle-ingest":
        return "group-ingest"
    if ctx.triggered_id == "btn-toggle-process":
        return "group-process"
    if ctx.triggered_id == "subflow" and dbl_clicked:
        node_id = dbl_clicked.get("id", "")
        if node_id.startswith("group-"):
            return node_id
    return dash.no_update


@callback(
    Output("subflow", "nodes"),
    Input("btn-expand-all", "n_clicks"),
    State("subflow", "nodes"),
    prevent_initial_call=True,
)
def expand_all(n_clicks, nodes):
    """Force-expand all groups by clearing collapsed state."""
    if not nodes:
        return dash.no_update
    updated = []
    for n in nodes:
        if n.get("type") == "group" and n.get("data", {}).get("collapsed"):
            n = dict(n)
            n["data"] = {**n["data"], "collapsed": False}
            # Restore original size from initial_nodes
            orig = next((x for x in initial_nodes if x["id"] == n["id"]), None)
            if orig and orig.get("style"):
                n["style"] = orig["style"]
            n["hidden"] = False
        elif n.get("hidden"):
            n = dict(n)
            n["hidden"] = False
        updated.append(n)
    return updated


@callback(
    Output("collapse-info", "children"),
    Input("subflow", "collapsedGroups"),
)
def show_collapsed(groups):
    if not groups:
        return "All groups expanded"
    return f"Collapsed: {', '.join(groups)}"


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `toggleCollapseNode` — set to a group node's `id` to flip its collapsed/expanded state
- `collapsedGroups` — an output prop listing the IDs of currently-collapsed groups, shown in the status line
- `data.collapsedWidth` / `data.collapsedHeight` — the size a group shrinks to once collapsed
- Architecture pattern: edges that cross a group boundary connect to the **group node**, not to a child inside it; edges between two children of the same group connect child-to-child directly
- `doubleClickedNode` — lets users collapse/expand by double-clicking the group header, in addition to the toggle buttons


---

*Source: /advanced*

---

<!-- /api — https://flows.2plot.dev/api/llms.txt -->

# API reference

## dash_flows

### AnimatedCircleNode

AnimatedCircleNode - A circular node designed for use with animated edges.
Features a distinctive pink circular design that works well with AnimatedNodeEdge
to create animated dot effects along connections.

| prop | type | default | description |
|---|---|---|---|
| `data` * | dict |  | Node data object containing the label content |

### AnimatedNodeEdge

AnimatedNodeEdge is a custom edge component that animates a node along its path.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID of the edge |
| `data` | dict | { animatedNode: '' } | Edge data containing the ID of the node to animate |
| `sourcePosition` | string |  | Position of the source handle |
| `sourceX` | number |  | X coordinate of the source node |
| `sourceY` | number |  | Y coordinate of the source node |
| `targetPosition` | string |  | Position of the target handle |
| `targetX` | number |  | X coordinate of the target node |
| `targetY` | number |  | Y coordinate of the target node |

### AnimatedSvgEdge

AnimatedSvgEdge - An edge that animates a custom SVG element along the path
Useful for showing data flow direction or active connections

| prop | type | default | description |
|---|---|---|---|
| `id` * | string |  | Unique identifier for the edge |
| `data` | dict |  | Configuration data for the animated SVG edge |
| `markerEnd` | any |  | Marker configuration for the edge end |
| `markerStart` | any |  | Marker configuration for the edge start |
| `selected` | bool |  | Whether the edge is currently selected |
| `sourcePosition` | string |  | Position of the source handle ('top', 'bottom', 'left', 'right') |
| `sourceX` * | number |  | X coordinate of the edge source |
| `sourceY` * | number |  | Y coordinate of the edge source |
| `style` | object |  | Custom CSS styles for the edge path |
| `targetPosition` | string |  | Position of the target handle ('top', 'bottom', 'left', 'right') |
| `targetX` * | number |  | X coordinate of the edge target |
| `targetY` * | number |  | Y coordinate of the edge target |

### ButtonEdge

ButtonEdge - Glass morphism styled edge with an interactive button and optional toolbar

| prop | type | default | description |
|---|---|---|---|
| `id` * | string |  | Unique identifier for the edge |
| `data` | dict |  | Configuration data for the button edge |
| `label` | any |  | Label text to display on the edge |
| `labelStyle` | object |  | Custom CSS styles for the label |
| `markerEnd` | any |  | Marker configuration for the edge end |
| `markerStart` | any |  | Marker configuration for the edge start |
| `selected` | bool |  | Whether the edge is currently selected |
| `sourcePosition` | string |  | Position of the source handle ('top', 'bottom', 'left', 'right') |
| `sourceX` * | number |  | X coordinate of the edge source |
| `sourceY` * | number |  | Y coordinate of the edge source |
| `style` | object |  | Custom CSS styles for the edge path |
| `targetPosition` | string |  | Position of the target handle ('top', 'bottom', 'left', 'right') |
| `targetX` * | number |  | X coordinate of the edge target |
| `targetY` * | number |  | Y coordinate of the edge target |

### ButtonHandle

ButtonHandle - A handle that can also function as a button
Useful for triggering actions when clicking on a connection point

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | Unique identifier for the handle |
| `buttonClassName` | string |  | CSS class for the button |
| `buttonContent` | node |  | Content to display in the button |
| `buttonStyle` | object |  | Inline styles for the button |
| `children` | node |  | Child content for the button |
| `className` | string |  | CSS class for the handle |
| `isConnectable` | bool | true | Whether the handle can connect to other nodes |
| `onClick` | func |  | Click handler for the button |
| `onConnect` | func |  | Called when a connection is made |
| `position` | one of 'top', 'bottom', 'left', 'right' | 'bottom' | Position of the handle on the node |
| `showButton` | bool | false | Whether to show the button (default: false) |
| `style` | object |  | Inline styles for the handle |
| `type` * | one of 'source', 'target' |  | Type of handle - source or target |

### DashFlows

DashFlows - A React Flow-based node graph component for Dash applications.
Provides interactive node-based workflow visualization with support for
custom node types, edge types, glass morphism styling, and extensive
callback integration for Python-based interactivity.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | The ID used to identify this component in Dash callbacks. |
| `addNodeOnEdgeDrop` | bool |  | When true, dragging a connection from a handle and dropping on empty canvas creates a new default node at that position and connects it. |
| `animateLayout` | bool | false | Enable smooth animated transitions when applying ELK layout. Nodes interpolate from current to target positions with an ease-out curve. |
| `animateLayoutDuration` | number | 300 | Duration of layout animation in milliseconds (default: 300) |
| `ariaLabelConfig` | object |  | ARIA label configuration for accessibility. Customize labels for screen readers. Keys: nodes, edges, controls, minimap. Values are label strings. |
| `autoPanOnConnect` | bool | true | Auto-pan viewport when making a new connection near edges |
| `autoPanOnNodeDrag` | bool | true | Auto-pan viewport when dragging a node near edges |
| `autoPanOnNodeFocus` | bool | true | Auto-pan the viewport when focusing a node via keyboard (Tab key). |
| `autoPanSpeed` | number | 15 | Speed of auto-panning (default: 15) |
| `backgroundColor` | string |  | Color of the background pattern |
| `backgroundGap` | number \| list of number | 16 | Gap between background pattern elements |
| `backgroundSize` | number | 1 | Size of background pattern elements |
| `backgroundVariant` | one of 'dots', 'lines', 'cross' | 'dots' | Background pattern type: 'dots', 'lines', 'cross' |
| `className` | string | '' | CSS class name for the container div |
| `clickedEdge` | dict |  | The edge that was clicked |
| `clickedNode` | dict |  | The node that was clicked |
| `clipboard` | dict |  | Internal clipboard containing copied nodes and edges. Populated by copyAction, consumed by pasteAction. |
| `collapsedGroups` | list of string |  | List of currently collapsed group node IDs (read-only output). |
| `colorMode` | one of 'light', 'dark', 'system' | 'light' | Color mode: 'light', 'dark', or 'system' |
| `colorScheme` | one of 'default', 'ocean', 'forest', 'sunset', 'midnight', 'rose' |  | Color scheme preset for node and edge colors. Works in combination with themePreset for full customization. Each scheme includes light and dark mode variants. - default: Neutral blue/purple (default) - ocean: Blues and teals - forest: Greens and browns - sunset: Oranges and reds - midnight: Deep blues and purples - rose: Pinks and reds |
| `computeAction` | dict |  | Trigger a graph computation. Set to { action: 'compute' } to perform topological sort and emit traversal order with input mappings. Will be reset to null after execution. |
| `computeResult` | dict |  | Result of the last computation. Contains traversalOrder (array of node IDs in topological order) and nodeInputs (map of nodeId to input details). |
| `connectOnClick` | bool | true | Enable click-based connection mode (click source then target) |
| `connectionDragThreshold` | number | 0 | Minimum drag distance in pixels before a connection line appears. Useful to prevent accidental connections. Default is 0 (immediate). |
| `connectionLineStyle` | object |  | Style for the connection line while dragging |
| `connectionLineType` | one of 'bezier', 'straight', 'step', 'smoothstep', 'simplebezier' | 'bezier' | Type of connection line: 'bezier', 'straight', 'step', 'smoothstep', 'simplebezier' |
| `connectionMode` | one of 'strict', 'loose' | 'strict' | Connection mode: 'strict' (same handle type) or 'loose' (any handle) |
| `connectionRadius` | number | 20 | Radius for connection drop detection (default: 20) |
| `connectionRules` | dict |  | Rules for validating connections |
| `connectionStartHandle` | dict |  | Current connection being created |
| `contextMenuEdge` | dict |  | Context menu info for an edge |
| `contextMenuNode` | dict |  | Context menu info for a node |
| `controlsPosition` | one of 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'top-center', 'bottom-center' | 'bottom-left' | Position of controls panel |
| `controlsShowFitView` | bool | true | Show fit-view button in controls |
| `controlsShowInteractive` | bool | true | Show interactive toggle in controls |
| `controlsShowZoom` | bool | true | Show zoom buttons in controls |
| `copyAction` | bool |  | Trigger to copy selected nodes and edges to clipboard. Set to true to copy. Will be reset to false after copying. If nothing is selected, copies all nodes and their connecting edges. |
| `defaultEdgeOptions` | dict |  | Default options applied to all new edges |
| `defaultMarkerColor` | string | '#555' | Default color for edge markers |
| `defaultViewport` | dict |  | The initial viewport position and zoom level |
| `deleteElementsAction` | dict |  | Delete specific nodes and/or edges by ID. Routes through reactFlowInstance.deleteElements() so undo/redo middleware can capture the change. Set to { nodeIds: [...], edgeIds: [...] }; auto-resets to null. |
| `deleteKeyCode` | string | 'Backspace' | Key code for deleting selected elements (default: 'Backspace', null to disable) |
| `deletedEdges` | list of string |  | IDs of recently deleted edges |
| `deletedNodes` | list of string |  | IDs of recently deleted nodes |
| `disableKeyboardA11y` | bool | false | Disable keyboard accessibility features |
| `doubleClickedEdge` | dict |  | The edge that was double-clicked |
| `doubleClickedNode` | dict |  | The node that was double-clicked |
| `downloadImage` | dict |  | Trigger to download the flow as an image. Set to a config object to initiate download. Will be reset to null after download.  Config options: - format: 'png' \| 'svg' \| 'jpeg' (default: 'png') - filename: string (default: 'flow') - quality: number 0-1 (default: 0.95, for jpeg only) - backgroundColor: string (default: '#ffffff') - pixelRatio: number (default: 2, for higher resolution) |
| `draggedNode` | dict |  | The node being dragged |
| `droppedNode` | dict |  | Node data from a drop event (drag and drop from external source). Contains the type, position, and data of the dropped item. Use this in a callback to create a new node at the drop position. |
| `edgeDroppedNode` | dict |  | Info about the node created by dropping an edge on empty canvas. Contains nodeId, position, sourceNodeId, sourceHandleId, handleType, timestamp. |
| `edges` | list of dict | [] | Array of edges defining connections between nodes |
| `edgesFocusable` | bool | true | Enable/disable keyboard focus on edges |
| `edgesReconnectable` | bool | true | Allow edges to be reconnected after creation |
| `elementsSelectable` | bool | true | Enable/disable the ability to select elements |
| `elevateEdgesOnSelect` | bool | false | Raise z-index of selected edges |
| `elevateNodesOnSelect` | bool | true | Raise z-index of selected nodes |
| `enableUndoRedo` | bool | false | Enable the undo/redo history system. When enabled, node and edge changes (add, remove, position drag-stop) are recorded to a history stack. |
| `exportFlowState` | bool |  | Trigger to export the current flow state. Set to true to export. After export, this will be reset to false and flowState will be populated. |
| `fitView` | bool | false | Automatically fit all nodes in view on initialization |
| `fitViewOptions` | dict |  | Options for fitView behavior |
| `flowState` | dict |  | The exported flow state as a serializable object containing nodes, edges, and viewport. This is populated when exportFlowState is triggered. |
| `helperLineThreshold` | number |  | Distance in pixels within which helper lines snap and appear (default: 5) |
| `helperLines` | bool |  | Enable visual alignment guides (helper lines) when dragging nodes. Lines appear when a node aligns with another node's top/center/bottom or left/center/right. |
| `hoveredEdge` | dict |  | The edge being hovered |
| `hoveredNode` | dict |  | The node being hovered |
| `imageDownloaded` | dict |  | Information about the last successful image download. Populated after a successful downloadImage operation. |
| `initialized` | bool |  | Whether the flow has been initialized |
| `lastConnection` | dict |  | The last connection that was made |
| `lastError` | dict |  | Last error that occurred |
| `layoutOptions` | string |  | Layout options for arranging nodes using the ELK layout engine (JSON string) |
| `maxZoom` | number | 2 | Maximum zoom level (default: 2) |
| `minZoom` | number | 0.5 | Minimum zoom level (default: 0.5) |
| `miniMapMaskColor` | string |  | Color for the minimap mask (viewport indicator) |
| `miniMapNodeBorderRadius` | number |  | Border radius for minimap nodes |
| `miniMapNodeColor` | string |  | Color function or string for minimap nodes |
| `miniMapNodeStrokeColor` | string |  | Stroke color for minimap nodes |
| `miniMapPannable` | bool | false | Allow panning by dragging the minimap |
| `miniMapPosition` | one of 'top-left', 'top-right', 'bottom-left', 'bottom-right' | 'bottom-right' | Position of the minimap |
| `miniMapZoomable` | bool | false | Allow zooming via the minimap |
| `multiSelectionKeyCode` | string |  | Key code for adding to selection |
| `noDragClassName` | string | 'nodrag' | CSS class name that prevents dragging when applied to elements |
| `noPanClassName` | string | 'nopan' | CSS class name that prevents panning when applied to elements |
| `noWheelClassName` | string | 'nowheel' | CSS class name that prevents wheel zoom when applied to elements |
| `nodeConnections` | object |  | Map of nodeId to connection metadata. Each entry has 'incoming' and 'outgoing' arrays with edge/node details. Updated automatically when edges change. |
| `nodeExtent` | list of list of number |  | Limit where nodes can be placed [[minX, minY], [maxX, maxY]] |
| `nodes` | list of dict | [] | Array of nodes to display in the flow |
| `nodesConnectable` | bool | true | Enable/disable the ability to make new connections between nodes |
| `nodesDraggable` | bool | true | Enable/disable node dragging behavior |
| `nodesFocusable` | bool | true | Enable/disable keyboard focus on nodes |
| `onlyRenderVisibleElements` | bool | false | Only render nodes and edges that are visible in the viewport |
| `panActivationKeyCode` | string | 'Space' | Key code to activate pan mode (default: 'Space') |
| `panOnDrag` | bool \| list of number | true | Enable/disable panning by dragging. Can be boolean or array of mouse buttons [0,1,2] |
| `panOnScroll` | bool | false | Enable panning by scrolling |
| `panOnScrollMode` | one of 'free', 'vertical', 'horizontal' | 'free' | Restrict scroll panning direction: 'free', 'vertical', 'horizontal' |
| `panOnScrollSpeed` | number | 0.5 | Speed of scroll-based panning (default: 0.5) |
| `paneClickPosition` | dict |  | Position where the pane was clicked |
| `paneContextMenu` | dict |  | Context menu info for the pane |
| `panels` | list of dict |  | Array of panel configurations for custom UI overlays |
| `pasteAction` | dict |  | Trigger to paste nodes and edges from clipboard. Set to an object with optional offset: { offset: { x: 50, y: 50 } } Will be reset to null after pasting. |
| `pastedElements` | dict |  | Information about the last paste operation. Contains the IDs of newly created nodes and edges. |
| `preventDelete` | bool | false | Enable delete prevention checks |
| `preventDeleteEdges` | list of string |  | List of edge IDs that cannot be deleted |
| `preventDeleteNodes` | list of string |  | List of node IDs that cannot be deleted |
| `preventScrolling` | bool | true | Prevent scrolling on the page when the mouse is over the flow |
| `reconnectRadius` | number | 10 | Radius for reconnection detection (default: 10) |
| `restoreFlowState` | dict |  | Import/restore a previously exported flow state. Set this to a flowState object to restore the flow. |
| `selectNodesOnDrag` | bool | true | Select nodes when dragging over them |
| `selectedEdges` | list of string |  | IDs of currently selected edges |
| `selectedNodes` | list of string |  | IDs of currently selected nodes |
| `selectionKeyCode` | string | 'Shift' | Key code for multi-selection box (default: 'Shift') |
| `selectionMode` | one of 'full', 'partial' | 'full' | Selection mode: 'full' (fully enclosed) or 'partial' (touching) |
| `selectionOnDrag` | bool | false | Enable selection box by dragging on the pane |
| `showBackground` | bool | true | Show/hide the background pattern |
| `showControls` | bool | true | Show/hide the control panel |
| `showDevTools` | bool | false | Show/hide the developer tools panel |
| `showMiniMap` | bool | true | Show/hide the minimap navigation component |
| `smartHandles` | bool |  | Enable smart handle positioning. When true, nodes render handles on all 4 sides and edges automatically connect to the closest handle pair based on relative node positions. This prevents edges from wrapping around nodes unnecessarily. |
| `snapGrid` | list of number | [15, 15] | The grid size for snapping [x, y] (default: [15, 15]) |
| `snapToGrid` | bool | false | Whether to snap nodes to a grid when dragging |
| `style` | object | {} | Custom CSS styles for the container div |
| `theme` | dict |  | Custom theme configuration. Overrides CSS variables for fine-grained control. All properties are optional - only specify what you want to customize. |
| `themePreset` | one of 'glass', 'solid', 'minimal' |  | Theme preset: 'glass' (default), 'solid', or 'minimal' - glass: Glassmorphism with blur and transparency - solid: Opaque nodes with subtle shadows (better for complex backgrounds) - minimal: Clean lines with minimal styling |
| `toggleCollapseNode` | string |  | Set to a group node ID to toggle its collapsed/expanded state. When collapsed, child nodes are hidden and boundary edges remap to the group. Will be reset to null after processing. |
| `trackNodeDrag` | bool | false | Track node position during drag (can be expensive) |
| `trackViewport` | bool | false | Track viewport changes during pan/zoom |
| `translateExtent` | list of list of number |  | Limit the viewport panning extent [[minX, minY], [maxX, maxY]] |
| `undoRedoAction` | dict |  | Trigger an undo or redo action. Set to { action: 'undo' } or { action: 'redo' }. Will be reset to null after execution. |
| `undoRedoMaxHistory` | number | 50 | Maximum number of history snapshots to keep (default: 50) |
| `undoRedoState` | dict |  | Current undo/redo state. Contains canUndo, canRedo, undoCount, redoCount. Updated automatically when history changes. |
| `viewport` | dict |  | Current viewport state (read-only, updated by callbacks) |
| `viewportAction` | dict |  | Trigger viewport actions programmatically. Set to an action object to execute. Will be reset to null after execution.  Supported actions: - { action: 'fitView', options: {...} } - { action: 'zoomIn', options: {...} } - { action: 'zoomOut', options: {...} } - { action: 'setZoom', zoom: 1.5, options: {...} } - { action: 'setCenter', x: 100, y: 100, options: {...} } - { action: 'setViewport', viewport: {x, y, zoom}, options: {...} } - { action: 'focusNode', nodeId: 'node-1', zoom: 1.5, duration: 500 } |
| `viewportMoving` | bool |  | Whether viewport is currently moving (panning/zooming) |
| `viewportOverlays` | list of dict |  | Array of overlay elements rendered in flow coordinates via ViewportPortal. Each overlay moves with the viewport (pan/zoom) and is positioned at (x, y) in flow space. |
| `zIndexMode` | one of 'default', 'elevate' |  | Z-index calculation mode. 'default' uses standard stacking. 'elevate' raises selected nodes and connected edges above all other elements. |
| `zoomActivationKeyCode` | string |  | Key code to activate zoom mode |
| `zoomOnDoubleClick` | bool | true | Enable zooming by double-clicking |
| `zoomOnPinch` | bool | true | Enable zooming by pinching on touch devices |
| `zoomOnScroll` | bool | true | Enable zooming by scrolling |

### DataEdge

DataEdge - An edge that displays data from the source node
Useful for showing data flow between nodes

| prop | type | default | description |
|---|---|---|---|
| `id` * | string |  | Unique identifier for the edge |
| `data` | dict |  | Configuration data for the data edge |
| `markerEnd` | any |  | Marker configuration for the edge end |
| `markerStart` | any |  | Marker configuration for the edge start |
| `selected` | bool |  | Whether the edge is currently selected |
| `source` * | string |  | ID of the source node to read data from |
| `sourcePosition` | string |  | Position of the source handle ('top', 'bottom', 'left', 'right') |
| `sourceX` * | number |  | X coordinate of the edge source |
| `sourceY` * | number |  | Y coordinate of the edge source |
| `style` | object |  | Custom CSS styles for the edge path |
| `targetPosition` | string |  | Position of the target handle ('top', 'bottom', 'left', 'right') |
| `targetX` * | number |  | X coordinate of the edge target |
| `targetY` * | number |  | Y coordinate of the edge target |

### DefaultNode

DefaultNode - Glass morphism styled node with source and target handles
Equivalent to React Flow's built-in 'default' node type

Uses CSS classes from glass-theme.css for styling, supporting:
- Light/dark mode via colorMode prop or Mantine color scheme
- Theme presets (glass, solid, minimal)
- Custom CSS variable overrides via theme prop
- Status indicators (initial, loading, success, error)
- Custom icons via DashIconify or any Dash component

| prop | type | default | description |
|---|---|---|---|
| `data` * | dict |  | Node data object containing label, styling, and handle configuration |
| `isConnectable` | bool |  | Whether connections can be made to/from this node |
| `selected` | bool |  | Whether the node is currently selected |

### DevTools

DevTools component for displaying debug information about the flow

| prop | type | default | description |
|---|---|---|---|
| `nodes` * | list of dict |  | Array of nodes to display information about |

### FloatingEdge

FloatingEdge - Edge that connects to the nearest point on each node's border
instead of fixed handle positions. Creates more natural-looking connections.

| prop | type | default | description |
|---|---|---|---|
| `id` * | string |  | Unique identifier for the edge |
| `data` | object |  | Additional data passed to the edge |
| `label` | any |  | Label text to display on the edge |
| `labelStyle` | object |  | Custom CSS styles for the label |
| `markerEnd` | any |  | Marker configuration for the edge end |
| `markerStart` | any |  | Marker configuration for the edge start |
| `selected` | bool |  | Whether the edge is currently selected |
| `source` * | string |  | Source node ID |
| `sourceX` | number |  | X coordinate of the edge source |
| `sourceY` | number |  | Y coordinate of the edge source |
| `style` | object |  | Custom CSS styles for the edge path |
| `target` * | string |  | Target node ID |
| `targetX` | number |  | X coordinate of the edge target |
| `targetY` | number |  | Y coordinate of the edge target |

### GroupNode

GroupNode - A simple labeled group container for child nodes

This follows the React Flow pattern for group nodes:
- The node's width/height is controlled via the style prop on the node definition
- Child nodes use parentId to reference this node
- Child nodes can use extent: 'parent' to stay within bounds
- Supports collapse/expand via data.collapsed (managed by DashFlows toggleCollapseNode prop)

Unlike standard nodes, group nodes are rendered as simple containers
that React Flow manages for parent-child relationships.

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | Node ID |
| `data` | dict |  | Node data object containing label, icon, and styling options |
| `selected` | bool |  | Whether the group is currently selected |

### InputNode

InputNode - Glass morphism styled node with only a source handle (no incoming connections)
Features a green accent bar at the top

Uses CSS classes from glass-theme.css for styling, supporting:
- Light/dark mode via colorMode prop or Mantine color scheme
- Theme presets (glass, solid, minimal)
- Custom CSS variable overrides via theme prop
- Custom icons via DashIconify or any Dash component

| prop | type | default | description |
|---|---|---|---|
| `data` * | dict |  | Node data configuration object containing display and behavior settings |
| `isConnectable` | bool |  | Whether connections can be made from this node |
| `selected` | bool |  | Whether the node is currently selected |

### NodeSearch

NodeSearch - A search panel for finding and focusing nodes
Supports searching by node id, label, or data properties

| prop | type | default | description |
|---|---|---|---|
| `className` | string |  | Additional CSS class name |
| `focusOnSelect` | bool | true | Whether to focus on the selected node |
| `highlightDuration` | number | 2000 | Duration of highlight effect in ms |
| `highlightOnSelect` | bool | true | Whether to highlight the selected node |
| `isOpen` | bool | false | Whether the search panel is open |
| `onClose` | func |  | Callback when the panel should close |
| `placeholder` | string | 'Search nodes...' | Placeholder text for the search input |
| `searchKeys` | list of string | ['id', 'label', 'type', 'title', 'name'] | Keys to search in node data (default: ['id', 'label', 'type']) |
| `style` | object |  | Custom inline styles |
| `zoomLevel` | number | 1.5 | Zoom level when focusing on a node |

### NodeStatusIndicator

NodeStatusIndicator - A wrapper component that shows status indicators around nodes
Status can be: "initial", "loading", "success", "error"
Loading variants: "border" (spinning border) or "overlay" (full overlay with spinner)

| prop | type | default | description |
|---|---|---|---|
| `children` | node |  | The node content to wrap |
| `className` | string |  | Additional CSS class name |
| `loadingVariant` | one of 'border', 'overlay' | 'border' | Loading animation variant - "border" shows spinning border, "overlay" shows full overlay |
| `status` | one of 'initial', 'loading', 'success', 'error' | 'initial' | The current status of the node |
| `style` | object |  | Custom inline styles |

### NodeTooltip

NodeTooltip - A wrapper that displays a tooltip when hovered
Built on top of React Flow's NodeToolbar component

| prop | type | default | description |
|---|---|---|---|
| `children` | node |  | The node content |
| `className` | string |  | CSS class for the container |
| `offset` | number | 10 | Offset from the node in pixels |
| `position` | one of 'top', 'bottom', 'left', 'right' | 'top' | Position of the tooltip relative to the node |
| `showOnHover` | bool | true | Show tooltip only on hover (default: true) |
| `style` | object |  | Inline styles for the container |
| `tooltipClassName` | string |  | CSS class for the tooltip |
| `tooltipContent` | node |  | Content to display in the tooltip |
| `tooltipStyle` | object |  | Inline styles for the tooltip |

### OutputNode

OutputNode - Glass morphism styled node with only a target handle (no outgoing connections)
Features a purple accent bar at the bottom

Uses CSS classes from glass-theme.css for styling, supporting:
- Light/dark mode via colorMode prop or Mantine color scheme
- Theme presets (glass, solid, minimal)
- Custom CSS variable overrides via theme prop
- Custom icons via DashIconify or any Dash component

| prop | type | default | description |
|---|---|---|---|
| `data` * | dict |  | Node data configuration object containing display and behavior settings |
| `isConnectable` | bool |  | Whether connections can be made to this node |
| `selected` | bool |  | Whether the node is currently selected |

### ResizableNode

ResizableNode - A node that can be resized by the user

This node supports embedding Dash components that will resize
along with the node container. The node receives width/height
from React Flow when resized.

| prop | type | default | description |
|---|---|---|---|
| `data` * | dict |  | Node data object containing label, handles, and styling options |
| `height` | number |  | Current height of the node (set by React Flow during resize) |
| `selected` | bool | false | Whether the node is currently selected |
| `width` | number |  | Current width of the node (set by React Flow during resize) |

### SimpleBezierEdge

SimpleBezierEdge - Glass morphism styled simple bezier curve edge

| prop | type | default | description |
|---|---|---|---|
| `id` * | string |  | Unique identifier for the edge |
| `data` | object |  | Additional data passed to the edge |
| `label` | any |  | Label text to display on the edge |
| `labelStyle` | object |  | Custom CSS styles for the label |
| `markerEnd` | any |  | Marker configuration for the edge end |
| `markerStart` | any |  | Marker configuration for the edge start |
| `selected` | bool |  | Whether the edge is currently selected |
| `sourcePosition` | string |  | Position of the source handle ('top', 'bottom', 'left', 'right') |
| `sourceX` * | number |  | X coordinate of the edge source |
| `sourceY` * | number |  | Y coordinate of the edge source |
| `style` | object |  | Custom CSS styles for the edge path |
| `targetPosition` | string |  | Position of the target handle ('top', 'bottom', 'left', 'right') |
| `targetX` * | number |  | X coordinate of the edge target |
| `targetY` * | number |  | Y coordinate of the edge target |

### SmoothStepEdge

SmoothStepEdge - Glass morphism styled stepped edge with rounded corners

| prop | type | default | description |
|---|---|---|---|
| `id` * | string |  | Unique identifier for the edge |
| `data` | object |  | Additional data passed to the edge (borderRadius can be set here) |
| `label` | any |  | Label text to display on the edge |
| `labelStyle` | object |  | Custom CSS styles for the label |
| `markerEnd` | any |  | Marker configuration for the edge end |
| `markerStart` | any |  | Marker configuration for the edge start |
| `selected` | bool |  | Whether the edge is currently selected |
| `sourcePosition` | string |  | Position of the source handle ('top', 'bottom', 'left', 'right') |
| `sourceX` * | number |  | X coordinate of the edge source |
| `sourceY` * | number |  | Y coordinate of the edge source |
| `style` | object |  | Custom CSS styles for the edge path |
| `targetPosition` | string |  | Position of the target handle ('top', 'bottom', 'left', 'right') |
| `targetX` * | number |  | X coordinate of the edge target |
| `targetY` * | number |  | Y coordinate of the edge target |

### StepEdge

StepEdge - Glass morphism styled stepped edge with sharp corners

| prop | type | default | description |
|---|---|---|---|
| `id` * | string |  | Unique identifier for the edge |
| `data` | object |  | Additional data passed to the edge |
| `label` | any |  | Label text to display on the edge |
| `labelStyle` | object |  | Custom CSS styles for the label |
| `markerEnd` | any |  | Marker configuration for the edge end |
| `markerStart` | any |  | Marker configuration for the edge start |
| `selected` | bool |  | Whether the edge is currently selected |
| `sourcePosition` | string |  | Position of the source handle ('top', 'bottom', 'left', 'right') |
| `sourceX` * | number |  | X coordinate of the edge source |
| `sourceY` * | number |  | Y coordinate of the edge source |
| `style` | object |  | Custom CSS styles for the edge path |
| `targetPosition` | string |  | Position of the target handle ('top', 'bottom', 'left', 'right') |
| `targetX` * | number |  | X coordinate of the edge target |
| `targetY` * | number |  | Y coordinate of the edge target |

### StraightEdge

StraightEdge - Glass morphism styled straight line edge

| prop | type | default | description |
|---|---|---|---|
| `id` * | string |  | Unique identifier for the edge |
| `data` | object |  | Additional data passed to the edge |
| `label` | any |  | Label text to display on the edge |
| `labelStyle` | object |  | Custom CSS styles for the label |
| `markerEnd` | any |  | Marker configuration for the edge end |
| `markerStart` | any |  | Marker configuration for the edge start |
| `selected` | bool |  | Whether the edge is currently selected |
| `sourceX` * | number |  | X coordinate of the edge source |
| `sourceY` * | number |  | Y coordinate of the edge source |
| `style` | object |  | Custom CSS styles for the edge path |
| `targetX` * | number |  | X coordinate of the edge target |
| `targetY` * | number |  | Y coordinate of the edge target |

### ToolbarNode

ToolbarNode - Glass morphism styled node with a configurable toolbar

| prop | type | default | description |
|---|---|---|---|
| `data` * | dict |  | Node data configuration object |
| `isConnectable` | bool |  | Whether connections can be made to/from this node |
| `selected` | bool |  | Whether the node is currently selected |

---

<!-- /api-reference — https://flows.2plot.dev/api-reference/llms.txt -->

# API Reference

> The full DashFlows prop table plus node and edge component props.

---



### Overview

Auto-generated prop tables for the dash-flows components. `DashFlows` is the main component; the node and edge components below are the custom types it can render.

### DashFlows component

The main component. Set `nodes`, `edges`, and a `style` with a `height`; everything else is optional. Interaction outputs (e.g. `clickedNode`, `selectedNodes`, `lastConnection`) are read in callbacks.


| Name | Type | Description |
| --- | --- | --- |
| id | string; optional | The ID used to identify this component in Dash callbacks. |
| addNodeOnEdgeDrop | boolean; optional | When True, dragging a connection from a handle and dropping on empty canvas creates a new default node at that position and connects it. |
| animateLayout | boolean; default False | Enable smooth animated transitions when applying ELK layout. Nodes interpolate from current to target positions with an ease-out curve. |
| animateLayoutDuration | number; default 300 | Duration of layout animation in milliseconds (default: 300). |
| ariaLabelConfig | dict; optional | ARIA label configuration for accessibility. Customize labels for screen readers. Keys: nodes, edges, controls, minimap. Values are label strings. |
| autoPanOnConnect | boolean; default True | Auto-pan viewport when making a new connection near edges. |
| autoPanOnNodeDrag | boolean; default True | Auto-pan viewport when dragging a node near edges. |
| autoPanOnNodeFocus | boolean; default True | Auto-pan the viewport when focusing a node via keyboard (Tab key). |
| autoPanSpeed | number; default 15 | Speed of auto-panning (default: 15). |
| backgroundColor | string; optional | Color of the background pattern. |
| backgroundGap | number \| list of numbers; default 16 | Gap between background pattern elements. |
| backgroundSize | number; default 1 | Size of background pattern elements. |
| backgroundVariant | a value equal to: 'dots', 'lines', 'cross'; default 'dots' | Background pattern type: 'dots', 'lines', 'cross'. |
| className | string; default '' | CSS class name for the container div. |
| clickedEdge | dict; optional | The edge that was clicked. `clickedEdge` is a dict with keys: - id (string; optional) - source (string; optional) - target (string; optional) |
| clickedNode | dict; optional | The node that was clicked. `clickedNode` is a dict with keys: - id (string; optional) - data (dict; optional) - position (dict; optional) |
| clipboard | dict; optional | Internal clipboard containing copied nodes and edges. Populated by copyAction, consumed by pasteAction. `clipboard` is a dict with keys: - nodes (list; optional) - edges (list; optional) - timestamp (number; optional) |
| collapsedGroups | list of strings; optional | List of currently collapsed group node IDs (read-only output). |
| colorMode | a value equal to: 'light', 'dark', 'system'; default 'light' | Color mode: 'light', 'dark', or 'system'. |
| colorScheme | a value equal to: 'default', 'ocean', 'forest', 'sunset', 'midnight', 'rose'; optional | Color scheme preset for node and edge colors. Works in combination with themePreset for full customization. Each scheme includes light and dark mode variants. - default: Neutral blue/purple (default) - ocean: Blues and teals - forest: Greens and browns - sunset: Oranges and reds - midnight: Deep blues and purples - rose: Pinks and reds. |
| computeAction | dict; optional | Trigger a graph computation. Set to { action: 'compute' } to perform topological sort and emit traversal order with input mappings. Will be reset to None after execution. `computeAction` is a dict with keys: - action (a value equal to: 'compute', 'computeNode'; optional) - nodeId (string; optional) - inputData (dict; optional) |
| computeResult | dict; optional | Result of the last computation. Contains traversalOrder (array of node IDs in topological order) and nodeInputs (map of nodeId to input details). `computeResult` is a dict with keys: - traversalOrder (list of strings; optional) - nodeInputs (dict; optional) - timestamp (number; optional) |
| connectOnClick | boolean; default True | Enable click-based connection mode (click source then target). |
| connectionDragThreshold | number; default 0 | Minimum drag distance in pixels before a connection line appears. Useful to prevent accidental connections. Default is 0 (immediate). |
| connectionLineStyle | dict; optional | Style for the connection line while dragging. |
| connectionLineType | a value equal to: 'bezier', 'straight', 'step', 'smoothstep', 'simplebezier'; default 'bezier' | Type of connection line: 'bezier', 'straight', 'step', 'smoothstep', 'simplebezier'. |
| connectionMode | a value equal to: 'strict', 'loose'; default 'strict' | Connection mode: 'strict' (same handle type) or 'loose' (any handle). |
| connectionRadius | number; default 20 | Radius for connection drop detection (default: 20). |
| connectionRules | dict; optional | Rules for validating connections. `connectionRules` is a dict with keys: - allowSelfConnection (boolean; optional) - allowDuplicateConnections (boolean; optional) - validSourceTypes (list of strings; optional) - validTargetTypes (list of strings; optional) |
| connectionStartHandle | dict; optional | Current connection being created. `connectionStartHandle` is a dict with keys: - nodeId (string; optional) - handleId (string; optional) - handleType (string; optional) |
| contextMenuEdge | dict; optional | Context menu info for an edge. `contextMenuEdge` is a dict with keys: - id (string; optional) - source (string; optional) - target (string; optional) - clientX (number; optional) - clientY (number; optional) |
| contextMenuNode | dict; optional | Context menu info for a node. `contextMenuNode` is a dict with keys: - id (string; optional) - data (dict; optional) - position (dict; optional) - clientX (number; optional) - clientY (number; optional) |
| controlsPosition | a value equal to: 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'top-center', 'bottom-center'; default 'bottom-left' | Position of controls panel. |
| controlsShowFitView | boolean; default True | Show fit-view button in controls. |
| controlsShowInteractive | boolean; default True | Show interactive toggle in controls. |
| controlsShowZoom | boolean; default True | Show zoom buttons in controls. |
| copyAction | boolean; optional | Trigger to copy selected nodes and edges to clipboard. Set to True to copy. Will be reset to False after copying. If nothing is selected, copies all nodes and their connecting edges. |
| defaultEdgeOptions | dict; optional | Default options applied to all new edges. `defaultEdgeOptions` is a dict with keys: - type (string; optional) - style (dict; optional) - animated (boolean; optional) - markerStart (dict; optional) - markerEnd (dict; optional) |
| defaultMarkerColor | string; default '#555' | Default color for edge markers. |
| defaultViewport | dict; optional | The initial viewport position and zoom level. `defaultViewport` is a dict with keys: - x (number; optional) - y (number; optional) - zoom (number; optional) |
| deleteElementsAction | dict; optional | Delete specific nodes and/or edges by ID. Routes through reactFlowInstance.deleteElements() so undo/redo middleware can capture the change. Set to { nodeIds: [...], edgeIds: [...] }; auto-resets to None. `deleteElementsAction` is a dict with keys: - nodeIds (list of strings; optional): IDs of nodes to delete (connected edges are also removed). - edgeIds (list of strings; optional): IDs of edges to delete. |
| deleteKeyCode | string; default 'Backspace' | Key code for deleting selected elements (default: 'Backspace', None to disable). |
| deletedEdges | list of strings; optional | IDs of recently deleted edges. |
| deletedNodes | list of strings; optional | IDs of recently deleted nodes. |
| disableKeyboardA11y | boolean; default False | Disable keyboard accessibility features. |
| doubleClickedEdge | dict; optional | The edge that was double-clicked. `doubleClickedEdge` is a dict with keys: - id (string; optional) - source (string; optional) - target (string; optional) |
| doubleClickedNode | dict; optional | The node that was double-clicked. `doubleClickedNode` is a dict with keys: - id (string; optional) - data (dict; optional) - position (dict; optional) |
| downloadImage | dict; optional | Trigger to download the flow as an image. Set to a config object to initiate download. Will be reset to None after download. Config options: - format: 'png' \| 'svg' \| 'jpeg' (default: 'png') - filename: string (default: 'flow') - quality: number 0-1 (default: 0.95, for jpeg only) - backgroundColor: string (default: '#ffffff') - pixelRatio: number (default: 2, for higher resolution). `downloadImage` is a dict with keys: - format (a value equal to: 'png', 'svg', 'jpeg', 'jpg'; optional) - filename (string; optional) - quality (number; optional) - backgroundColor (string; optional) - pixelRatio (number; optional) |
| draggedNode | dict; optional | The node being dragged. `draggedNode` is a dict with keys: - id (string; optional) - isDragging (boolean; optional) - startPosition (dict; optional) - currentPosition (dict; optional) - endPosition (dict; optional) |
| droppedNode | dict; optional | Node data from a drop event (drag and drop from external source). Contains the type, position, and data of the dropped item. Use this in a callback to create a new node at the drop position. `droppedNode` is a dict with keys: - type (string; optional) - position (dict; optional) `position` is a dict with keys: - x (number; optional) - y (number; optional) - data (dict; optional) - clientX (number; optional) - clientY (number; optional) - timestamp (number; optional) |
| edgeDroppedNode | dict; optional | Info about the node created by dropping an edge on empty canvas. Contains nodeId, position, sourceNodeId, sourceHandleId, handleType, timestamp. `edgeDroppedNode` is a dict with keys: - nodeId (string; optional) - position (dict; optional) `position` is a dict with keys: - x (number; optional) - y (number; optional) - sourceNodeId (string; optional) - sourceHandleId (string; optional) - handleType (string; optional) - timestamp (number; optional) |
| edges | list of dicts; optional | Array of edges defining connections between nodes. `edges` is a list of dicts with keys: - id (string; required) - source (string; required) - target (string; required) - sourceHandle (string; optional) - targetHandle (string; optional) - type (string; optional) - data (dict; optional) - style (dict; optional) - className (string; optional) - hidden (boolean; optional) - selected (boolean; optional) - animated (boolean; optional) - deletable (boolean; optional) - selectable (boolean; optional) - focusable (boolean; optional) - zIndex (number; optional) - ariaLabel (string; optional) - interactionWidth (number; optional) - label (string; optional) - labelStyle (dict; optional) - labelShowBg (boolean; optional) - labelBgStyle (dict; optional) - labelBgPadding (list of numbers; optional) - labelBgBorderRadius (number; optional) - markerStart (dict; optional) `markerStart` is a string Or dict with keys: - type (string; required) - color (string; optional) - width (number; optional) - height (number; optional) - markerUnits (string; optional) - orient (string; optional) - strokeWidth (number; optional) - markerEnd (dict; optional) `markerEnd` is a string \| dict with keys: - type (string; required) - color (string; optional) - width (number; optional) - height (number; optional) - markerUnits (string; optional) - orient (string; optional) - strokeWidth (number; optional) |
| edgesFocusable | boolean; default True | Enable/disable keyboard focus on edges. |
| edgesReconnectable | boolean; default True | Allow edges to be reconnected after creation. |
| elementsSelectable | boolean; default True | Enable/disable the ability to select elements. |
| elevateEdgesOnSelect | boolean; default False | Raise z-index of selected edges. |
| elevateNodesOnSelect | boolean; default True | Raise z-index of selected nodes. |
| enableUndoRedo | boolean; default False | Enable the undo/redo history system. When enabled, node and edge changes (add, remove, position drag-stop) are recorded to a history stack. |
| exportFlowState | boolean; optional | Trigger to export the current flow state. Set to True to export. After export, this will be reset to False and flowState will be populated. |
| fitView | boolean; default False | Automatically fit all nodes in view on initialization. |
| fitViewOptions | dict; optional | Options for fitView behavior. `fitViewOptions` is a dict with keys: - padding (number; optional) - includeHiddenNodes (boolean; optional) - minZoom (number; optional) - maxZoom (number; optional) - duration (number; optional) - nodes (list of dicts; optional) `nodes` is a list of dicts with keys: - id (string; optional) |
| flowState | dict; optional | The exported flow state as a serializable object containing nodes, edges, and viewport. This is populated when exportFlowState is triggered. `flowState` is a dict with keys: - nodes (list; optional) - edges (list; optional) - viewport (dict; optional) `viewport` is a dict with keys: - x (number; optional) - y (number; optional) - zoom (number; optional) |
| helperLineThreshold | number; optional | Distance in pixels within which helper lines snap and appear (default: 5). |
| helperLines | boolean; optional | Enable visual alignment guides (helper lines) when dragging nodes. Lines appear when a node aligns with another node's top/center/bottom or left/center/right. |
| hoveredEdge | dict; optional | The edge being hovered. `hoveredEdge` is a dict with keys: - id (string; optional) - source (string; optional) - target (string; optional) |
| hoveredNode | dict; optional | The node being hovered. `hoveredNode` is a dict with keys: - id (string; optional) - data (dict; optional) |
| imageDownloaded | dict; optional | Information about the last successful image download. Populated after a successful downloadImage operation. `imageDownloaded` is a dict with keys: - filename (string; optional) - format (string; optional) - timestamp (number; optional) |
| initialized | boolean; optional | Whether the flow has been initialized. |
| lastConnection | dict; optional | The last connection that was made. `lastConnection` is a dict with keys: - source (string; optional) - sourceHandle (string; optional) - target (string; optional) - targetHandle (string; optional) |
| lastError | dict; optional | Last error that occurred. `lastError` is a dict with keys: - id (string; optional) - message (string; optional) - type (string; optional) - timestamp (number; optional) |
| layoutOptions | string; optional | Layout options for arranging nodes using the ELK layout engine (JSON string). |
| maxZoom | number; default 2 | Maximum zoom level (default: 2). |
| minZoom | number; default 0.5 | Minimum zoom level (default: 0.5). |
| miniMapMaskColor | string; optional | Color for the minimap mask (viewport indicator). |
| miniMapNodeBorderRadius | number; optional | Border radius for minimap nodes. |
| miniMapNodeColor | string; optional | Color function or string for minimap nodes. |
| miniMapNodeStrokeColor | string; optional | Stroke color for minimap nodes. |
| miniMapPannable | boolean; default False | Allow panning by dragging the minimap. |
| miniMapPosition | a value equal to: 'top-left', 'top-right', 'bottom-left', 'bottom-right'; default 'bottom-right' | Position of the minimap. |
| miniMapZoomable | boolean; default False | Allow zooming via the minimap. |
| multiSelectionKeyCode | string; optional | Key code for adding to selection. |
| noDragClassName | string; default 'nodrag' | CSS class name that prevents dragging when applied to elements. |
| noPanClassName | string; default 'nopan' | CSS class name that prevents panning when applied to elements. |
| noWheelClassName | string; default 'nowheel' | CSS class name that prevents wheel zoom when applied to elements. |
| nodeConnections | dict; optional | Map of nodeId to connection metadata. Each entry has 'incoming' and 'outgoing' arrays with edge/node details. Updated automatically when edges change. |
| nodeExtent | list of list of numberss; optional | Limit where nodes can be placed [[minX, minY], [maxX, maxY]]. |
| nodes | list of dicts; optional | Array of nodes to display in the flow. `nodes` is a list of dicts with keys: - id (string; required) - type (string; optional) - data (dict; required) - position (dict; required) `position` is a dict with keys: - x (number; required) - y (number; required) - style (dict; optional) - className (string; optional) - hidden (boolean; optional) - selected (boolean; optional) - draggable (boolean; optional) - selectable (boolean; optional) - connectable (boolean; optional) - deletable (boolean; optional) - dragHandle (string; optional) - width (number; optional) - height (number; optional) - parentId (string; optional) - zIndex (number; optional) - extent (string \| list of list of numberss; optional) - expandParent (boolean; optional) - positionAbsolute (dict; optional) `positionAbsolute` is a dict with keys: - x (number; optional) - y (number; optional) - ariaLabel (string; optional) - focusable (boolean; optional) - resizing (boolean; optional) |
| nodesConnectable | boolean; default True | Enable/disable the ability to make new connections between nodes. |
| nodesDraggable | boolean; default True | Enable/disable node dragging behavior. |
| nodesFocusable | boolean; default True | Enable/disable keyboard focus on nodes. |
| onlyRenderVisibleElements | boolean; default False | Only render nodes and edges that are visible in the viewport. |
| panActivationKeyCode | string; default 'Space' | Key code to activate pan mode (default: 'Space'). |
| panOnDrag | boolean \| list of numbers; default True | Enable/disable panning by dragging. Can be boolean or array of mouse buttons [0,1,2]. |
| panOnScroll | boolean; default False | Enable panning by scrolling. |
| panOnScrollMode | a value equal to: 'free', 'vertical', 'horizontal'; default 'free' | Restrict scroll panning direction: 'free', 'vertical', 'horizontal'. |
| panOnScrollSpeed | number; default 0.5 | Speed of scroll-based panning (default: 0.5). |
| paneClickPosition | dict; optional | Position where the pane was clicked. `paneClickPosition` is a dict with keys: - clientX (number; optional) - clientY (number; optional) |
| paneContextMenu | dict; optional | Context menu info for the pane. `paneContextMenu` is a dict with keys: - clientX (number; optional) - clientY (number; optional) |
| panels | list of dicts; optional | Array of panel configurations for custom UI overlays. `panels` is a list of dicts with keys: - position (a value equal to: 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'top-center', 'bottom-center'; optional) - children (boolean \| number \| string \| dict \| list; optional) |
| pasteAction | dict; optional | Trigger to paste nodes and edges from clipboard. Set to an object with optional offset: { offset: { x: 50, y: 50 } } Will be reset to None after pasting. `pasteAction` is a dict with keys: - offset (dict; optional) `offset` is a dict with keys: - x (number; optional) - y (number; optional) |
| pastedElements | dict; optional | Information about the last paste operation. Contains the IDs of newly created nodes and edges. `pastedElements` is a dict with keys: - nodeIds (list of strings; optional) - edgeIds (list of strings; optional) - timestamp (number; optional) |
| preventDelete | boolean; default False | Enable delete prevention checks. |
| preventDeleteEdges | list of strings; optional | List of edge IDs that cannot be deleted. |
| preventDeleteNodes | list of strings; optional | List of node IDs that cannot be deleted. |
| preventScrolling | boolean; default True | Prevent scrolling on the page when the mouse is over the flow. |
| reconnectRadius | number; default 10 | Radius for reconnection detection (default: 10). |
| restoreFlowState | dict; optional | Import/restore a previously exported flow state. Set this to a flowState object to restore the flow. `restoreFlowState` is a dict with keys: - nodes (list; optional) - edges (list; optional) - viewport (dict; optional) `viewport` is a dict with keys: - x (number; optional) - y (number; optional) - zoom (number; optional) |
| selectNodesOnDrag | boolean; default True | Select nodes when dragging over them. |
| selectedEdges | list of strings; optional | IDs of currently selected edges. |
| selectedNodes | list of strings; optional | IDs of currently selected nodes. |
| selectionKeyCode | string; default 'Shift' | Key code for multi-selection box (default: 'Shift'). |
| selectionMode | a value equal to: 'full', 'partial'; default 'full' | Selection mode: 'full' (fully enclosed) or 'partial' (touching). |
| selectionOnDrag | boolean; default False | Enable selection box by dragging on the pane. |
| showBackground | boolean; default True | Show/hide the background pattern. |
| showControls | boolean; default True | Show/hide the control panel. |
| showDevTools | boolean; default False | Show/hide the developer tools panel. |
| showMiniMap | boolean; default True | Show/hide the minimap navigation component. |
| smartHandles | boolean; optional | Enable smart handle positioning. When True, nodes render handles on all 4 sides and edges automatically connect to the closest handle pair based on relative node positions. This prevents edges from wrapping around nodes unnecessarily. |
| snapGrid | list of numbers; default [15, 15] | The grid size for snapping [x, y] (default: [15, 15]). |
| snapToGrid | boolean; default False | Whether to snap nodes to a grid when dragging. |
| theme | dict; optional | Custom theme configuration. Overrides CSS variables for fine-grained control. All properties are optional - only specify what you want to customize. `theme` is a dict with keys: - glassBlur (number; optional): Glass blur intensity in pixels (default: 12). - glassSaturate (number; optional): Glass saturation percentage (default: 180). - nodeBackground (string; optional): Node background color (e.g., 'rgba(255, 255, 255, 0.72)'). - nodeBorder (string; optional): Node border color. - nodeText (string; optional): Node primary text color. - nodeTextSecondary (string; optional): Node secondary/subtitle text color. - inputNodeBackground (string; optional): Input node background color. - inputNodeBorder (string; optional): Input node border color. - inputNodeAccent (string; optional): Input node accent color (top bar). - outputNodeBackground (string; optional): Output node background color. - outputNodeBorder (string; optional): Output node border color. - outputNodeAccent (string; optional): Output node accent color (bottom bar). - edgeStroke (string; optional): Edge stroke color. - edgeStrokeSelected (string; optional): Selected edge stroke color. - edgeStrokeAnimated (string; optional): Animated edge stroke color. - edgeStrokeWidth (number; optional): Edge stroke width in pixels. - handleBackground (string; optional): Handle background color. - handleBorder (string; optional): Handle border color. - handleConnected (string; optional): Connected handle color. - backgroundColor (string; optional): Flow background color. - backgroundPattern (string; optional): Background pattern color. - selectionBackground (string; optional): Selection box background color. - selectionBorder (string; optional): Selection box border color. - borderRadius (number; optional): Node border radius in pixels. |
| themePreset | a value equal to: 'glass', 'solid', 'minimal'; optional | Theme preset: 'glass' (default), 'solid', or 'minimal' - glass: Glassmorphism with blur and transparency - solid: Opaque nodes with subtle shadows (better for complex backgrounds) - minimal: Clean lines with minimal styling. |
| toggleCollapseNode | string; optional | Set to a group node ID to toggle its collapsed/expanded state. When collapsed, child nodes are hidden and boundary edges remap to the group. Will be reset to None after processing. |
| trackNodeDrag | boolean; default False | Track node position during drag (can be expensive). |
| trackViewport | boolean; default False | Track viewport changes during pan/zoom. |
| translateExtent | list of list of numberss; optional | Limit the viewport panning extent [[minX, minY], [maxX, maxY]]. |
| undoRedoAction | dict; optional | Trigger an undo or redo action. Set to { action: 'undo' } or { action: 'redo' }. Will be reset to None after execution. `undoRedoAction` is a dict with keys: - action (a value equal to: 'undo', 'redo'; optional) |
| undoRedoMaxHistory | number; default 50 | Maximum number of history snapshots to keep (default: 50). |
| undoRedoState | dict; optional | Current undo/redo state. Contains canUndo, canRedo, undoCount, redoCount. Updated automatically when history changes. `undoRedoState` is a dict with keys: - canUndo (boolean; optional) - canRedo (boolean; optional) - undoCount (number; optional) - redoCount (number; optional) |
| viewport | dict; optional | Current viewport state (read-only, updated by callbacks). `viewport` is a dict with keys: - x (number; optional) - y (number; optional) - zoom (number; optional) |
| viewportAction | dict; optional | Trigger viewport actions programmatically. Set to an action object to execute. Will be reset to None after execution. Supported actions: - { action: 'fitView', options: {...} } - { action: 'zoomIn', options: {...} } - { action: 'zoomOut', options: {...} } - { action: 'setZoom', zoom: 1.5, options: {...} } - { action: 'setCenter', x: 100, y: 100, options: {...} } - { action: 'setViewport', viewport: {x, y, zoom}, options: {...} } - { action: 'focusNode', nodeId: 'node-1', zoom: 1.5, duration: 500 }. `viewportAction` is a dict with keys: - action (a value equal to: 'fitView', 'zoomIn', 'zoomOut', 'setZoom', 'setCenter', 'setViewport', 'focusNode'; optional) - nodeId (string; optional) - x (number; optional) - y (number; optional) - zoom (number; optional) - duration (number; optional) - viewport (dict; optional) `viewport` is a dict with keys: - x (number; optional) - y (number; optional) - zoom (number; optional) - options (dict; optional) |
| viewportMoving | boolean; optional | Whether viewport is currently moving (panning/zooming). |
| viewportOverlays | list of dicts; optional | Array of overlay elements rendered in flow coordinates via ViewportPortal. Each overlay moves with the viewport (pan/zoom) and is positioned at (x, y) in flow space. `viewportOverlays` is a list of dicts with keys: - x (number; optional): X position in flow coordinates. - y (number; optional): Y position in flow coordinates. - content (string; optional): Text content to display. - style (dict; optional): Custom CSS styles for the overlay div. |
| zIndexMode | a value equal to: 'default', 'elevate'; optional | Z-index calculation mode. 'default' uses standard stacking. 'elevate' raises selected nodes and connected edges above all other elements. |
| zoomActivationKeyCode | string; optional | Key code to activate zoom mode. |
| zoomOnDoubleClick | boolean; default True | Enable zooming by double-clicking. |
| zoomOnPinch | boolean; default True | Enable zooming by pinching on touch devices. |
| zoomOnScroll | boolean; default True | Enable zooming by scrolling. |


### Node components

Custom node types you can register and render inside a flow.


| Name | Type | Description |
| --- | --- | --- |
| data | dict; required | Node data object containing label, styling, and handle configuration. `data` is a dict with keys: - label (boolean \| number \| string \| dict \| list; optional): Primary content to display in the node - string or Dash component. - title (boolean \| number \| string \| dict \| list; optional): Alias for label - use for clarity when also using body text. - sublabel (string; optional): Secondary text displayed below the main label. - body (boolean \| number \| string \| dict \| list; optional): Body text displayed below title/sublabel. - icon (boolean \| number \| string \| dict \| list; optional): Custom icon - DashIconify component or any Dash component. - iconColor (string; optional): Background color for the icon container. - showIcon (boolean; optional): Show/hide icon (default: True if icon prop provided, False otherwise). - layout (a value equal to: 'stacked', 'horizontal'; optional): Layout mode: 'stacked' (vertical) or 'horizontal' (icon left, text right). - multiline (boolean; optional): Allow multiline text wrapping. - style (dict; optional): Custom CSS styles for the node container. - className (string; optional): Additional CSS class name. - handleStyle (dict; optional): Custom CSS styles for connection handles. - targetPosition (string; optional): Position for the target (input) handle: 'top', 'bottom', 'left', 'right'. - sourcePosition (string; optional): Position for the source (output) handle: 'top', 'bottom', 'left', 'right'. - status (a value equal to: 'initial', 'loading', 'success', 'error'; optional): Node status: 'initial', 'loading', 'success', 'error'. - loadingVariant (a value equal to: 'border', 'overlay'; optional): Loading animation variant: 'border' or 'overlay'. - smartHandles (boolean; optional): Enable smart handles mode - renders handles on all 4 sides for optimal edge routing. |
| isConnectable | boolean; optional | Whether connections can be made to/from this node. |
| selected | boolean; optional | Whether the node is currently selected. |



| Name | Type | Description |
| --- | --- | --- |
| id | string; optional | Node ID. |
| data | dict; optional | Node data object containing label, icon, and styling options. `data` is a dict with keys: - label (boolean \| number \| string \| dict \| list; optional): Label content for the group - displayed in top-left corner. - icon (boolean \| number \| string \| dict \| list; optional): Icon element to display next to the label. - labelStyle (dict; optional): Custom CSS styles for the label element. - resizable (boolean; optional): Whether the group can be resized (default: True). - minWidth (number; optional): Minimum width constraint for resizing. - minHeight (number; optional): Minimum height constraint for resizing. - maxWidth (number; optional): Maximum width constraint for resizing. - maxHeight (number; optional): Maximum height constraint for resizing. - keepAspectRatio (boolean; optional): Maintain aspect ratio when resizing. - collapsed (boolean; optional): Whether the group is collapsed (managed by DashFlows toggleCollapseNode). - collapsedWidth (number; optional): Width when collapsed (default: 150). - collapsedHeight (number; optional): Height when collapsed (default: 50). |
| selected | boolean; optional | Whether the group is currently selected. |


### Edge components


| Name | Type | Description |
| --- | --- | --- |
| id | string; required | Unique identifier for the edge. |
| data | dict; optional | Configuration data for the button edge. `data` is a dict with keys: - label (boolean \| number \| string \| dict \| list; optional): Label text to display next to the button. - showButton (boolean; optional): Whether to show the button (default: True). - buttonLabel (string; optional): Text/symbol to display on the button (default: '×'). - buttonStyle (dict; optional): Custom CSS styles for the button. - buttonTitle (string; optional): Tooltip title for the button. - onButtonClick (optional): Callback function when button is clicked. - showToolbar (boolean; optional): Show glass morphism toolbar when edge is selected. - toolbarStyle (dict; optional): Custom CSS styles for the toolbar container. |
| label | boolean \| number \| string \| dict \| list; optional | Label text to display on the edge. |
| labelStyle | dict; optional | Custom CSS styles for the label. |
| markerEnd | boolean \| number \| string \| dict \| list; optional | Marker configuration for the edge end. |
| markerStart | boolean \| number \| string \| dict \| list; optional | Marker configuration for the edge start. |
| selected | boolean; optional | Whether the edge is currently selected. |
| sourcePosition | string; optional | Position of the source handle ('top', 'bottom', 'left', 'right'). |
| sourceX | number; required | X coordinate of the edge source. |
| sourceY | number; required | Y coordinate of the edge source. |
| targetPosition | string; optional | Position of the target handle ('top', 'bottom', 'left', 'right'). |
| targetX | number; required | X coordinate of the edge target. |
| targetY | number; required | Y coordinate of the edge target. |



| Name | Type | Description |
| --- | --- | --- |
| id | string; required | Unique identifier for the edge. |
| data | dict; optional | Additional data passed to the edge. |
| label | boolean \| number \| string \| dict \| list; optional | Label text to display on the edge. |
| labelStyle | dict; optional | Custom CSS styles for the label. |
| markerEnd | boolean \| number \| string \| dict \| list; optional | Marker configuration for the edge end. |
| markerStart | boolean \| number \| string \| dict \| list; optional | Marker configuration for the edge start. |
| selected | boolean; optional | Whether the edge is currently selected. |
| source | string; required | Source node ID. |
| sourceX | number; optional | X coordinate of the edge source. |
| sourceY | number; optional | Y coordinate of the edge source. |
| target | string; required | Target node ID. |
| targetX | number; optional | X coordinate of the edge target. |
| targetY | number; optional | Y coordinate of the edge target. |


---

*Source: /api-reference*

---

<!-- /canvas — https://flows.2plot.dev/canvas/llms.txt -->

# Canvas & Controls

> Background variants, zoom/pan controls, the minimap, and viewport control.

---



### Overview

Toggle the on-canvas chrome with `showBackground`, `showControls`, and `showMiniMap`, and pick a `backgroundVariant` of `dots`, `lines`, or `cross`. The viewport can also be driven programmatically from callbacks via `viewportAction` and `fitView`.

### 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/canvas/demo.py

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

nodes = [{'id': 'a', 'type': 'input', 'data': {'label': 'Source'}, 'position': {'x': 120, 'y': 40}},
 {'id': 'b', 'type': 'default', 'data': {'label': 'Transform'}, 'position': {'x': 120, 'y': 180}},
 {'id': 'c', 'type': 'output', 'data': {'label': 'Sink'}, 'position': {'x': 120, 'y': 320}}]

edges = [{'id': 'ab', 'source': 'a', 'target': 'b', 'animated': True},
 {'id': 'bc', 'source': 'b', 'target': 'c'}]

component = dash_flows.DashFlows(
    id="canvas-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=True,
    showBackground=True,
    backgroundVariant='cross',
    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>`.

#### Background Variants

Swap between the `dots`, `lines`, and `cross` background patterns and tweak their color, gap, and size — handy when picking a background that fits your theme.



```python
# File: examples/04_background_variants.py

"""
Example 04: Background Variants
===============================
This example demonstrates the different background patterns available:
- dots: Grid of dots (default)
- lines: Horizontal and vertical lines
- cross: Crosshatch pattern
- Custom colors and sizes
"""

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

app = dash.Dash(__name__)

nodes = [
    {"id": "1", "type": "default", "data": {"label": "Node A"}, "position": {"x": 100, "y": 100}},
    {"id": "2", "type": "default", "data": {"label": "Node B"}, "position": {"x": 300, "y": 100}},
    {"id": "3", "type": "default", "data": {"label": "Node C"}, "position": {"x": 200, "y": 250}},
]

edges = [
    {"id": "e1", "source": "1", "target": "3"},
    {"id": "e2", "source": "2", "target": "3"},
]

app.layout = dmc.MantineProvider([
    html.H1("Background Variants Example"),
    html.P("Use the controls below to change the background pattern."),

    dmc.Group([
        dmc.Select(
            id="bg-variant",
            label="Background Variant",
            data=[
                {"value": "dots", "label": "Dots"},
                {"value": "lines", "label": "Lines"},
                {"value": "cross", "label": "Cross"},
            ],
            value="dots",
            style={"width": 150},
        ),
        dmc.ColorInput(
            id="bg-color",
            label="Pattern Color",
            value="#e5e7eb",
            style={"width": 150},
        ),
        dmc.NumberInput(
            id="bg-gap",
            label="Gap Size",
            value=20,
            min=5,
            max=50,
            style={"width": 100},
        ),
        dmc.NumberInput(
            id="bg-size",
            label="Pattern Size",
            value=1,
            min=0.5,
            max=5,
            step=0.5,
            style={"width": 100},
        ),
    ], style={"marginBottom": 20}),

    html.Div(id="flow-container"),
])

@callback(
    Output("flow-container", "children"),
    Input("bg-variant", "value"),
    Input("bg-color", "value"),
    Input("bg-gap", "value"),
    Input("bg-size", "value"),
)
def update_background(variant, color, gap, size):
    return dash_flows.DashFlows(
        id="background-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        # Background configuration
        backgroundVariant=variant,
        backgroundGap=gap,
        backgroundSize=size,
        backgroundColor=color,
    )

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `backgroundVariant` — one of `"dots"`, `"lines"`, or `"cross"`; controlled here by a `dmc.Select`.
- `backgroundColor` — CSS color for the pattern, wired to a `dmc.ColorInput`.
- `backgroundGap` / `backgroundSize` — spacing and scale of the pattern, driven by `dmc.NumberInput`.
- The callback rebuilds the whole `DashFlows` component on every control change rather than patching individual props — simple and fine for background-only settings.

#### Controls and MiniMap

A larger 5x6 node grid shows off the zoom/pan control bar and the type-colored `MiniMap`, including position and pannable/zoomable toggles.



```python
# File: examples/05_controls_and_minimap.py

"""
Example 05: Controls and MiniMap
================================
This example demonstrates:
- Zoom controls (zoom in, zoom out, fit view, lock interactivity)
- MiniMap with custom styling
- Control positioning options
"""

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

app = dash.Dash(__name__)

# Create a larger flow to demonstrate MiniMap utility
nodes = []
edges = []

# Create a grid of nodes
for row in range(5):
    for col in range(6):
        node_id = f"node-{row}-{col}"
        nodes.append({
            "id": node_id,
            "type": "default",
            "data": {"label": f"({row},{col})"},
            "position": {"x": col * 200, "y": row * 150},
        })
        # Connect horizontally
        if col > 0:
            edges.append({
                "id": f"h-{row}-{col}",
                "source": f"node-{row}-{col-1}",
                "target": node_id,
            })
        # Connect vertically
        if row > 0:
            edges.append({
                "id": f"v-{row}-{col}",
                "source": f"node-{row-1}-{col}",
                "target": node_id,
            })

app.layout = dmc.MantineProvider([
    html.H1("Controls and MiniMap Example"),
    html.P("A large flow with zoom controls and minimap navigation."),

    dmc.Group([
        dmc.Switch(id="show-controls", label="Show Controls", checked=True),
        dmc.Switch(id="show-minimap", label="Show MiniMap", checked=True),
        dmc.Switch(id="pannable-minimap", label="Pannable MiniMap", checked=True),
        dmc.Switch(id="zoomable-minimap", label="Zoomable MiniMap", checked=True),
    ], style={"marginBottom": 20}),

    dmc.Group([
        dmc.Select(
            id="controls-position",
            label="Controls Position",
            data=[
                {"value": "top-left", "label": "Top Left"},
                {"value": "top-right", "label": "Top Right"},
                {"value": "bottom-left", "label": "Bottom Left"},
                {"value": "bottom-right", "label": "Bottom Right"},
            ],
            value="bottom-left",
            style={"width": 150},
        ),
        dmc.Select(
            id="minimap-position",
            label="MiniMap Position",
            data=[
                {"value": "top-left", "label": "Top Left"},
                {"value": "top-right", "label": "Top Right"},
                {"value": "bottom-left", "label": "Bottom Left"},
                {"value": "bottom-right", "label": "Bottom Right"},
            ],
            value="bottom-right",
            style={"width": 150},
        ),
    ], style={"marginBottom": 20}),

    html.Div(id="controls-flow-container"),
])

@callback(
    Output("controls-flow-container", "children"),
    Input("show-controls", "checked"),
    Input("show-minimap", "checked"),
    Input("pannable-minimap", "checked"),
    Input("zoomable-minimap", "checked"),
    Input("controls-position", "value"),
    Input("minimap-position", "value"),
)
def update_flow(show_controls, show_minimap, pannable, zoomable, ctrl_pos, mm_pos):
    return dash_flows.DashFlows(
        id="controls-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "600px", "border": "1px solid #ddd"},
        fitView=True,
        # Controls configuration
        showControls=show_controls,
        controlsPosition=ctrl_pos,
        controlsShowZoom=True,
        controlsShowFitView=True,
        controlsShowInteractive=True,
        # MiniMap configuration
        showMiniMap=show_minimap,
        miniMapPosition=mm_pos,
        miniMapPannable=pannable,
        miniMapZoomable=zoomable,
    )

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `showControls` / `controlsPosition` — toggle and place the zoom-in/zoom-out/fit-view/lock control bar.
- `controlsShowZoom`, `controlsShowFitView`, `controlsShowInteractive` — enable individual buttons within the control bar.
- `showMiniMap` / `miniMapPosition` — toggle and place the minimap overlay.
- `miniMapPannable` / `miniMapZoomable` — let the minimap itself be dragged or scrolled to navigate large graphs.
- With dozens of nodes, the minimap becomes the fastest way to orient yourself after zooming in.

#### Viewport Controls

Drive the camera from Dash callbacks: fit view, zoom in/out, reset zoom, and pan to named positions, plus switches to lock zooming, panning, or node dragging.



```python
# File: examples/09_viewport_controls.py

"""
Example 09: Viewport Controls and Fit View
==========================================
This example demonstrates viewport manipulation:
- Zoom in/out programmatically
- Pan to specific positions
- Fit view to content
- Lock/unlock viewport
- Get/set viewport state
"""

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

app = dash.Dash(__name__, suppress_callback_exceptions=True)

# Create nodes spread across the canvas
nodes = [
    {"id": "1", "type": "default", "data": {"label": "Top Left"}, "position": {"x": 0, "y": 0}},
    {"id": "2", "type": "default", "data": {"label": "Top Right"}, "position": {"x": 800, "y": 0}},
    {"id": "3", "type": "default", "data": {"label": "Center"}, "position": {"x": 400, "y": 250}},
    {"id": "4", "type": "default", "data": {"label": "Bottom Left"}, "position": {"x": 0, "y": 500}},
    {"id": "5", "type": "default", "data": {"label": "Bottom Right"}, "position": {"x": 800, "y": 500}},
]

edges = [
    {"id": "e1-3", "source": "1", "target": "3"},
    {"id": "e2-3", "source": "2", "target": "3"},
    {"id": "e3-4", "source": "3", "target": "4"},
    {"id": "e3-5", "source": "3", "target": "5"},
]

app.layout = dmc.MantineProvider([
    html.H1("Viewport Controls Example"),
    html.P("Control the viewport programmatically."),

    dmc.Group([
        dmc.Button("Fit View", id="btn-fit-view", variant="filled"),
        dmc.Button("Zoom In", id="btn-zoom-in", variant="outline"),
        dmc.Button("Zoom Out", id="btn-zoom-out", variant="outline"),
        dmc.Button("Reset Zoom", id="btn-reset-zoom", variant="outline"),
    ], style={"marginBottom": 10}),

    dmc.Group([
        dmc.Button("Pan to Top Left", id="btn-pan-tl", variant="light", size="sm"),
        dmc.Button("Pan to Center", id="btn-pan-center", variant="light", size="sm"),
        dmc.Button("Pan to Bottom Right", id="btn-pan-br", variant="light", size="sm"),
    ], style={"marginBottom": 10}),

    dmc.Group([
        dmc.Switch(id="lock-zoom", label="Lock Zoom", checked=False),
        dmc.Switch(id="lock-pan", label="Lock Pan", checked=False),
        dmc.Switch(id="lock-drag", label="Lock Node Drag", checked=False),
    ], style={"marginBottom": 20}),

    html.Div(id="flow-viewport-container"),

    dmc.Space(h=20),

    dmc.Paper([
        dmc.Text("Current Viewport State:", fw=600),
        html.Pre(id="viewport-state", style={"fontSize": "11px"}),
    ], p="md", withBorder=True),
])

@callback(
    Output("flow-viewport-container", "children"),
    Input("lock-zoom", "checked"),
    Input("lock-pan", "checked"),
    Input("lock-drag", "checked"),
)
def update_flow_locks(lock_zoom, lock_pan, lock_drag):
    return dash_flows.DashFlows(
        id="viewport-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "400px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        # Viewport lock settings
        zoomOnScroll=not lock_zoom,
        zoomOnPinch=not lock_zoom,
        zoomOnDoubleClick=not lock_zoom,
        panOnDrag=not lock_pan,
        panOnScroll=not lock_pan,
        nodesDraggable=not lock_drag,
        # Zoom limits
        minZoom=0.1,
        maxZoom=4,
    )

# Clientside callback for viewport manipulation
app.clientside_callback(
    """
    function(fitClicks, zoomInClicks, zoomOutClicks, resetClicks, panTLClicks, panCenterClicks, panBRClicks) {
        const triggered = dash_clientside.callback_context.triggered;
        if (!triggered || triggered.length === 0) {
            return window.dash_clientside.no_update;
        }

        const btnId = triggered[0].prop_id.split('.')[0];

        // These would need to be handled by the component itself
        // This is a placeholder to show the concept
        switch(btnId) {
            case 'btn-fit-view':
                return {'action': 'fitView', 'options': {padding: 0.2}};
            case 'btn-zoom-in':
                return {'action': 'zoomIn', 'options': {}};
            case 'btn-zoom-out':
                return {'action': 'zoomOut', 'options': {}};
            case 'btn-reset-zoom':
                return {'action': 'setZoom', 'zoom': 1, 'options': {}};
            case 'btn-pan-tl':
                return {'action': 'setCenter', 'x': 0, 'y': 0, 'options': {zoom: 1, duration: 500}};
            case 'btn-pan-center':
                return {'action': 'setCenter', 'x': 450, 'y': 300, 'options': {zoom: 1, duration: 500}};
            case 'btn-pan-br':
                return {'action': 'setCenter', 'x': 900, 'y': 550, 'options': {zoom: 1, duration: 500}};
            default:
                return window.dash_clientside.no_update;
        }
    }
    """,
    Output("viewport-flow", "viewportAction", allow_duplicate=True),
    Input("btn-fit-view", "n_clicks"),
    Input("btn-zoom-in", "n_clicks"),
    Input("btn-zoom-out", "n_clicks"),
    Input("btn-reset-zoom", "n_clicks"),
    Input("btn-pan-tl", "n_clicks"),
    Input("btn-pan-center", "n_clicks"),
    Input("btn-pan-br", "n_clicks"),
    prevent_initial_call=True,
)

@callback(
    Output("viewport-state", "children"),
    Input("viewport-flow", "viewport"),
)
def display_viewport(viewport):
    if not viewport:
        return "Viewport not available"
    return json.dumps(viewport, indent=2)

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `viewportAction` — an input prop you set from a callback to imperatively move the camera, e.g. `{"action": "fitView", "options": {"padding": 0.2}}` or `{"action": "setCenter", "x": 450, "y": 300, "options": {"zoom": 1, "duration": 500}}`.
- The buttons here use a clientside callback (`clientside_callback`) so the viewport reacts instantly without a server round-trip.
- `zoomOnScroll`, `zoomOnPinch`, `zoomOnDoubleClick`, `panOnDrag`, `panOnScroll`, `nodesDraggable` — booleans for locking specific interactions, toggled by the switches above.
- `minZoom` / `maxZoom` — clamp how far in/out the viewport can go.
- `viewport` — a read-only output prop reporting the current `{x, y, zoom}`, rendered live below the canvas.


---

*Source: /canvas*

---

<!-- /changelog — https://flows.2plot.dev/changelog/llms.txt -->

# Changelog

> Version history of dash-flows. The timeline on this page is rendered from `CHANGELOG.md`, reproduced below.

---

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **dash-improve-my-llms floored at 2.8.0** (the ledger round, sync items
  12+13). `lib/analytics_tracker.py` now delegates bot classification to
  the package's `classify()` instead of its own year-old User-Agent list
  (which filed ClaudeBot — Anthropic's *training* crawler — under
  "search"). Reporting consequence: `human_hits` DROPS and `bot_hits`
  RISES on the day this deploys, because UA-less and library clients
  (`httpx`, `Go-http-client`, `node-fetch`, an empty UA) move from human
  to crawler. That is the number becoming true, not a regression — the
  hub's day-over-day view will show the step.
- **The ledger gains a `reads` table** (`on_document_read` → new
  `AnalyticsTracker.record_read`): one row per corpus document the
  package serves (tier, verdict, bytes, verified vendor). `lib/
  traffic_rollup.py`'s rollup gains an additive `vendors[]` block on any
  day with reads, and a new `/admin/traffic` page shows this host its
  own vendor × day / vendor → tier ledger, gated exactly like the
  control board.
- **Render now deploys the `release` branch**, written only by
  `.github/workflows/cd.yml`'s `deploy` job as a fast-forward push after
  the CI matrix is green (sync item 13). A push to `main` is a
  candidate, not a deploy; `verify` now runs only when `deploy` succeeds
  and re-checks `/healthz build == $GITHUB_SHA` itself before smoke
  testing. See `DIVERGENCES.md`'s posture fence (`deploy:
  release-branch`).
- **Training crawlers (GPTBot, ClaudeBot, CCBot, …) are allowed by
  default** (sync item 15): `run.py`'s `RobotsConfig(block_ai_training=
  False, …)` — since sync item 12 every corpus read is a ledger row this
  app keeps, so a training-crawler fetch is now recorded and priceable
  instead of walled outright. `robots.txt` carries no `Disallow` stanza
  for them at all; the package's bot middleware stops 403ing `/` and
  `/healthz` for them too. `allow_ai_search` / `allow_traditional` are
  unchanged.
- **Navigation, /changelog, /api and /admin/traffic follow the fleet's
  navigation contract** (sync item 16): the sidebar's sections now come
  from each docs page's frontmatter (`category:` + `order:`) against a
  fork-owned `CATEGORY_ORDER`, never a hand-edited `page_order` list; the
  top bar gains an **Other Apps** hover menu (the network's primary
  applications, from `lib/network_directory.py`) and a version badge; the
  sidebar's Resources section is third-party only (`dmc` + React Flow);
  a new `/changelog` page renders this file as a timeline; a new `/api`
  page documents every `dash_flows` component's props, generated from
  the installed package's `metadata.json`; `/admin/traffic` gains a
  `dmc.DatePickerInput` day picker (replacing a bare `dcc.Dropdown`) and
  a **People** section separating the v3 human numbers from the crawler
  ledger below it; `pages/home.py` renders through markdown2dash instead
  of `dcc.Markdown`. Admin pages (`/admin/control-board`,
  `/admin/traffic`) are hidden from the sidebar and search unless the
  viewer is the owner. See `DIVERGENCES.md` divergence 11 for the two
  lines this fork kept (the wordmark, the SPA page-view beacon).
- **The network battery's default User-Agent names the browser lane**
  (sync item 17): `scripts/network_smoke.py`'s `UA` now leads with a
  Chrome/AppleWebKit token before the internal token — at
  dash-improve-my-llms ≥ 2.8 a UA with no browser engine token is
  crawler-lane, so the old bare-internal-token default made every
  default-UA check (the manifest link, the social card) read the
  prerendered crawler document instead of the browser one.
- **The 1.6.41/1.6.42 navigation-contract remainder** (sync item 18):
  frontmatter gains a `nav:` short-sidebar-label field (separate from
  `name:`, which stays the `<title>`/og:title/llms.txt heading) and
  sidebar links show a lock icon on `auth`/`admin`-tier pages;
  `/api`'s sitemap `lastmod` now reads the committed
  `dash_flows/api_metadata.json` extract's `generated` stamp (built by
  `python scripts/build_api_metadata.py` — re-run it when a
  component's props change) instead of always being absent; `/changelog`
  parses every heading shape the fleet writes (hyphen, en dash, em
  dash, bare or bracketed version, prose-first releases with no `###`
  sections) and gains a sitemap `lastmod` of its own; the AppShell
  gains a keyboard skip-link to `#main-content`; `lib/constants.py`
  gains `LOGO_ICON`/`WORDMARK_COLOR`/`WORDMARK_VISIBLE_FROM`, lifting
  the header's identity out of `components/header.py`.

### Fixed

- **`docs/api_reference/api_reference.md`'s `.. kwargs::` prop tables
  reached the browser only** (sync item 18 contract highlight
  7-amended, muicharts' finding): a markdown2dash directive renders
  Dash components, so its table exists only in the React tree — the
  machine lane (`/api-reference/llms.txt`, the crawler document) and
  the non-JS prerender are built from the markdown source, where the
  directive line is stripped. Measured before the fix: zero markdown
  table rows anywhere outside a JavaScript browser, across all five
  `.. kwargs::` directives on that page. `pages/markdown.py` now
  expands `.. kwargs::` fence-aware into a real markdown table, the
  same treatment it already gave `.. source::`, through a new
  `lib.directives.kwargs.props_markdown()` — one parse
  (`props_for()`) for both the directive's own table and the
  expansion, so the two lanes cannot drift apart again.
- **This file had a phantom release.** The `## Migration Guide`
  section at the foot of this CHANGELOG parsed as a release (the
  1.6.41 heading match accepts unbracketed `## label — date`, and
  free text came through with it), rendering an extra Timeline card
  with no version number. `pages/changelog.py` now requires an
  UNBRACKETED `## ` heading to look like a release — a version, an
  ISO date, or Unreleased — before treating it as one; bracketed
  headings (`## [x.y.z]`) stay trusted as intent.
- `tests/test_nav_contract.py`'s "every test client names a UA" pin
  is now resolved per CALL SITE instead of per file — the file-scoped
  substring check could pass a file whose `headers=` sat on an
  unrelated code path while its actual `.test_client()` calls stayed
  bare. `tests/test_excluded_links_hidden.py`'s admin-leak sweep now
  covers `/llms-small.txt` and `/llms-full.txt`, not only `/llms.txt`.
- **The read ledger counted the network's own health sweeps and
  batteries as crawler traffic** (sync 1.6.43 item 1): `record_read`
  — the `on_document_read` hook the 2.8.0 floor added — never learned
  the internal-traffic contract `track_visit` has held since it
  existed. A request carrying `lib.constants.INTERNAL_UA_TOKEN` now
  drops before any field is read, keyed on the event's `ua` field
  (not `user_agent`, which `EVENT_FIELDS` does not have).
- **`/healthz` read as missing two keys to every automated reader**
  (sync item 10): this host served `dash` where the fleet key set
  names `dash_version`, and no `backend` at all. Every value on the
  wire was correct, but the hub's hourly sweep, the F4 battery,
  `cd.yml`'s build-match wait and `scripts/network_smoke.py` all read
  the payload BY KEY NAME, so a renamed key is indistinguishable from
  a missing one. `dash_version` and `backend` are now served BESIDE
  `dash` (additive — an extra costs nothing, a substitute costs a red
  cell). `DIVERGENCES.md` §3 corrected in the same change: it had
  recorded this key set as a deliberate divergence, which is what kept
  several rounds of syncs from fixing it — the file layout is the real
  divergence, the key set never was.

## [1.3.0] - 2026-08-01

### Changed — flows.2plot.dev joins the 2plot network standard

The docs site now matches the pattern proven on 2plot.ai, 2plot.dev,
boilerplate.2plot.dev, leaflet.2plot.dev and email.2plot.dev. What that means
in practice:

- **One identity, every surface.** `lib/constants.py` defines
  `SITE_BRAND = "dash-flows — React Flow node graphs for Dash"` (package name
  leads — the library rule; "Pip Install Python" is the byline, never the
  name), and it now reaches the `<title>`, the `/llms.txt` H1, the llms
  viewer's brand chip, the home prose, the README and every share-card
  headline. `tests/test_site_identity.py` pins all of them, because every one
  of these falls back to a framework default silently.
- **`dash-improve-my-llms>=2.3.4` is now required** (run.py refuses to boot
  below the floor) and replaces `lib/seo.py` entirely: `/robots.txt`,
  `/sitemap.xml`, `/llms.txt`, `/<page>/llms.txt`, the crawler prerender,
  canonical/og:url and the cross-host `## Network` directory
  (`lib/network_directory.py`) all come from the package. The one thing
  `lib/seo.py` did that the package does not — re-pointing canonical/og:url on
  client-side navigation — moved into `templates/index.html` as the
  network-standard History-API script.
- **The analytics stack is the network's.** `lib/satellite_analytics.py` split
  into the standard `lib/analytics_tracker.py` (which drops internal-network
  and `/healthz` traffic at WRITE time — the internal-traffic contract's
  inbound half), `lib/traffic_rollup.py`, `lib/satellite_reporter.py` (flock
  lease so one worker reports per interval) and `lib/bulletin.py`. This app's
  own SPA page-view beacon — the reason its session numbers are honest when
  every other Dash site reports single-page visits — survives the split as
  `lib/pageview_beacon.py`. The outbound half of the contract is new here:
  `ad_client` and the traffic reporter now send the `2plot-internal` UA, so
  this site's readers stop being counted as bot traffic on the hub.
- **The app id is `flows` everywhere** — the network-directory key, never the
  package name — across `AD_APP_ID`, `SATELLITE_APP_KEY`
  and the bulletin.
- **Every page ships a real social card.** No `register_page()` call passed
  `image_url=` before, so Dash emitted `og:image=""` on every page — an empty
  tag that unfurls worse than none, because scrapers treat it as the declared
  image and render a blank card. All pages now point at
  `cdn.2plot.ai/github_assets/flows.2plot.dev.png` (1200x630, rendered by
  `scripts/make_social_card.py`), the template declares only the auxiliary
  tags Dash omits, and `scripts/smoke_live.py` reads the CDN object's IHDR
  bytes after every deploy.
- **gunicorn floored at 23** (CVE-2024-6827, CVE-2024-1135 — request
  smuggling). markdown2dash 0.1.2 pins `gunicorn<22`, so it now installs with
  `--no-deps` everywhere (Dockerfile, CI, requirements-docs.txt lists its real
  dependencies) and CI asserts the gunicorn version *inside* the built image.

### Added — CI/CD, from nothing

This repo had no `.github/workflows` at all. It now has the boilerplate's
proven shape: `ci.yml` (actionlint first — an invalid workflow file is the one
defect CI structurally cannot report; flake8; the secretless pytest suite —
zero secrets in CI on purpose, so fail-closed postures are provable; the real
Docker image built, version-fingerprinted inside, booted and probed by
`scripts/network_smoke.py` — the same battery CD runs against production; an
examples matrix across Dash 4.2.0/4.4.1 and Python 3.10–3.13; a wheel build
proving the `dash>=4.1.0` floor in a clean venv with nothing but Dash) and
`cd.yml` (owns `main`, requires CI, then 120s settle + five *consecutive*
healthy probes — Render swaps instances, so a single 200 can come from the
dying instance — then the battery plus `scripts/smoke_live.py` against
https://flows.2plot.dev, where peer checks warn and own-host checks fail).

### Changed — the wheel states its real floor

`setup.py` now declares `install_requires=['dash>=4.1.0']` (the first release
with the multi-backend constructor this component targets). CI installs the
wheel with exactly `dash==4.1.0` and nothing else, and asserts
`top_level.txt == ["dash_flows"]` so no docs-site directory can ever leak into
the package.

### Changed
- **Dash 4.2+ support**: minimum `dash>=4.0.0` (dev pin `dash[dev]>=4.2.0`). Dash 4 keeps
  React 18, so no component-side React migration was required. Multi-backend
  (Flask/FastAPI/Quart) and websocket callbacks are available to consuming apps.
- **dash-mantine-components** pinned to `>=2.8.0` for examples and the docs app.
- **@xyflow/react** bumped to `^12.11.1` (latest 12.x). `zustand` remains pinned to `4.x`
  (React Flow requires `^4.4.0`).

### Added
- **Markdown-driven documentation app** at the repo root (`run.py`, `pages/`, `docs/`,
  `components/`, `lib/`) seeded from the Dash Documentation Boilerplate. Each component
  is documented in `docs/<topic>/<topic>.md` with live `.. exec::` demos, `.. source::`
  code, and `.. kwargs::` prop tables generated from the existing `examples/`.
- **Every example runnable inline in the docs**: 34 of the 35 `examples/` apps now have
  an embeddable twin (`docs/<topic>/exNN.py`, ids namespaced `exNN-`) rendered live via
  `.. exec::`, plus the full source and a "How it works" breakdown on its category page.
  `23_callback_stress_test` is intentionally source-only (performance harness).
- **Validation harnesses** committed under `scripts/`: `smoke_examples.py` (import gate),
  `smoke_runtime.py` (WSGI runtime gate: `/`, `/_dash-layout`, `/_dash-dependencies`),
  and `validate_docs.py` (docs render gate). Results matrix in `SMOKE_RESULTS.md`
  (35/35 import + runtime). Browser-fidelity test in `tests/test_examples_render.py`.

### Removed
- **R and Julia language bindings** dropped — the project targets Python/Dash only.
  Removed `R/`, `man/`, `src/jl/`, `src/DashFlows.jl`, `NAMESPACE`, `DESCRIPTION`,
  `Project.toml`, `.Rbuildignore`, and the duplicate JS bundle copies under `deps/` and
  `inst/`. `build:backends` no longer generates R/Julia (`--r-prefix`/`--jl-prefix` removed).
- Repo clutter removed from version control: `SKILLS.md`, `dash_flows_SKILLS.md`,
  `review_checklist.md`, `REACT_FLOW_COMPARISON.md`, and the tracked `.idea/` IDE config.
- **Pre-migration remnants** removed: `usage.py` (superseded by the docs app — use
  `python run.py`), the old JS dev demo (`src/demo/`, root `index.html`,
  `webpack.serve.config.js`, the `npm start` script, `webpack-dev-server`), the stale
  boilerplate tests `tests/test_usage.py`/`tests/test_handle.py` (replaced by
  `tests/test_examples_render.py`), unreferenced `assets/logo.svg`/`assets/group-node.png`,
  the unused `webpack` require in `webpack.config.js`, and the deprecated `babel-eslint`
  parser (now `@babel/eslint-parser`; run `npm install` once to refresh the lockfile).

### Fixed
- Populated the previously empty `LICENSE` file with the MIT license text.
- **"Maximum update depth exceeded" render loop**: the outbound `nodes`/`edges`
  `setProps` effects in `DashFlows.react.js` now guard on a serialized diff
  (mirroring the `nodeConnections` effect), so React Flow's asynchronous node
  re-measurement can no longer drive a `setProps`↔`setNodes` loop.
- **React Flow error 008 ("null source/target handle") on resizable nodes**:
  `ResizableNode` now renders a default target (top) + source (bottom) handle
  pair when `data.handles` is omitted or empty (matching `DefaultNode`), so edges
  bind to it instead of failing. The `data.handles` prop is now optional.
- **MiniMap ignored the light/dark preference**: removed the inline
  `colorMode`-based background override so the MiniMap follows the `--df-minimap-*`
  CSS variables, which track both React Flow's `.dark` class and Mantine's
  `data-mantine-color-scheme` (correct even for `colorMode="system"`).
- **Dark-mode fidelity across the docs example twins**: replaced hardcoded light
  colors on cards/panels/buttons with Mantine CSS variables; converted native
  `html.Button`s to `dmc.Button` (`ex15`/`ex18`/`ex19`); synced the `ex20`/`ex21`
  canvases' `colorMode` to the page theme via `color-scheme-storage`; and themed
  the `ex14` metric-card node text so it stays legible on dark canvases.

### Roadmap (carried over from REACT_FLOW_COMPARISON.md)
- Refactor `DashFlows.react.js` (~2500 lines) into focused hooks
  (`useSmartHandles`, `useFlowState`, `useConnectionValidation`, `useViewportActions`,
  `useImageExport`, `useCopyPaste`, `useElkLayout`).
- Lazy-load `elkjs` (~1.45 MB, ~77% of the bundle) via dynamic import on first
  `layoutOptions` use.
- Remove the `ramda` dependency in favor of native JS.
- Memoize smart-handle edge computation; document `onlyRenderVisibleElements` for large flows.

## [1.2.0] - 2026-04-03

### Added

#### React Flow 12.10.1 Pass-Through Props
- **connectionDragThreshold** (number): Minimum drag distance in pixels before a connection line appears, preventing accidental connections
- **zIndexMode** (`"default"` / `"elevate"`): Selected elements automatically elevate above others when set to `"elevate"`
- **autoPanOnNodeFocus** (boolean): Viewport auto-pans when focusing nodes via Tab key for improved keyboard navigation

#### Floating Edges
- **FloatingEdge component**: New `floating` edge type that connects to the nearest point on each node's border instead of fixed handle positions
- Uses `useInternalNode` hook for real-time border intersection calculation
- Natural-looking connections for arbitrary node layouts

#### Helper Lines (Alignment Guides)
- **helperLines** (boolean): Visual alignment guides appear when dragging nodes near other nodes
- **helperLineThreshold** (number, default 5): Snap distance in pixels for alignment detection
- Automatic snapping to horizontal and vertical alignment

#### Add Node on Edge Drop
- **addNodeOnEdgeDrop** (boolean): Drag a connection from a handle and drop on empty canvas to create a new node
- **edgeDroppedNode** (output): Callback data with `nodeId`, `position`, `source`, and `handleType` of the dropped connection

#### Node Connections Tracking
- **nodeConnections** (output): Real-time map of `nodeId` to `{incoming: [...], outgoing: [...]}` with edge/node details
- Automatically updated when edges change

#### Undo/Redo System
- **enableUndoRedo** (boolean): Enable history tracking for node/edge changes
- **undoRedoMaxHistory** (number, default 50): Maximum number of history snapshots
- **undoRedoAction** (`{action: 'undo' | 'redo'}`): Trigger undo or redo from callbacks
- **undoRedoState** (output): `{canUndo, canRedo, undoCount, redoCount}` for UI state
- Tracks node position changes, additions, deletions, and edge connections
- Uses React Flow's `experimental_useOnNodesChangeMiddleware` and `experimental_useOnEdgesChangeMiddleware`

#### Computing Flows (Graph Traversal)
- **computeAction** (`{action: 'compute' | 'computeNode', nodeId?, inputData?}`): Trigger topological sort computation
- **computeResult** (output): `{traversalOrder, nodeInputs, timestamp}` with Kahn's algorithm results
- JavaScript-side topological sort; Python handles all business logic and value propagation

#### Sub-flows (Collapsible Group Nodes)
- **toggleCollapseNode** (string): Set to a group node ID to toggle its collapse/expand state
- **collapsedGroups** (output): Array of currently collapsed group node IDs
- Double-click a group node to toggle collapse
- Child nodes and internal edges hidden when collapsed; group shrinks to compact view
- External edges remain connected at the group level
- GroupNode supports `data.collapsedWidth` and `data.collapsedHeight` for compact dimensions

#### ViewportPortal (Floating Annotations)
- **viewportOverlays** (array): Floating overlays anchored to flow coordinates that move with pan/zoom
- Each overlay: `{x, y, content, style}` — positioned in flow space, not screen space
- Add, remove, and edit annotations dynamically via callbacks

#### Animated Layout Transitions
- **animateLayout** (boolean): Smooth animated transitions when applying ELK layouts
- **animateLayoutDuration** (number, default 300): Animation duration in milliseconds
- Ease-out cubic interpolation from current to target positions

#### Accessibility (ARIA Support)
- **ariaLabelConfig** (object): Custom ARIA labels for `nodes`, `edges`, `controls`, `minimap` regions
- Per-node/edge `ariaLabel` in data for screen reader support
- **nodesFocusable** / **edgesFocusable** (boolean): Enable Tab key navigation through nodes and edges
- **disableKeyboardA11y** (boolean): Disable keyboard shortcuts when needed

#### Resize Constraints
- ResizableNode and GroupNode now support constraint props in `data`:
  - `keepAspectRatio` (boolean): Lock aspect ratio during resize
  - `minWidth` / `minHeight` (number): Minimum dimensions in pixels
  - `maxWidth` / `maxHeight` (number): Maximum dimensions in pixels

#### Glass Connection Line
- Custom glass morphism styled connection line preview while dragging new edges
- Blue dashed line with glow and drop shadow effect

#### Type-Colored MiniMap
- Custom MiniMap node rendering with colors based on node type (green=input, blue=default, purple=output, amber=toolbar)

#### EdgeToolbar on ButtonEdge
- ButtonEdge now supports `data.showToolbar` (boolean) to display an EdgeToolbar on selection
- Toolbar includes edit (inline label editing) and delete actions
- Custom styling via `data.toolbarStyle`

#### Smart Handle Positioning
- **smartHandles** (boolean): Automatically routes edges to the closest side of each node
- Renders handles on all 4 sides and selects optimal connection points
- Alternative manual control via `data.sourcePosition` / `data.targetPosition` per node

#### Delete Elements Action
- **deleteElementsAction** (`{nodeIds: [...], edgeIds: [...]}`): Programmatic deletion routed through `reactFlowInstance.deleteElements()` for undo/redo compatibility

#### New Examples
- **Example 24**: `24_smart_handles.py` — Smart handle auto-routing demo
- **Example 25**: `25_phase1_features.py` — Phase 1 feature showcase (drag threshold, zIndex, auto-pan, EdgeToolbar)
- **Example 26**: `26_floating_edges.py` — Floating edge border intersection demo
- **Example 27**: `27_helper_lines.py` — Alignment guide and snap demo
- **Example 28**: `28_add_node_on_drop.py` — Add node on edge drop with connection tracking
- **Example 29**: `29_accessibility.py` — ARIA labels and keyboard navigation
- **Example 30**: `30_resize_constraints.py` — Aspect ratio and min/max resize limits
- **Example 31**: `31_animated_layout.py` — Smooth layout transitions with ELK
- **Example 32**: `32_undo_redo.py` — Full undo/redo system demo
- **Example 33**: `33_computing_flows.py` — Topological sort and data flow computation
- **Example 34**: `34_viewport_portal.py` — Floating viewport annotations
- **Example 35**: `35_subflows.py` — Collapsible group nodes with boundary edge remapping

#### New Python Wrapper Components (auto-generated)
- `AnimatedSvgEdge`, `ButtonEdge`, `ButtonHandle`, `DataEdge`, `DefaultNode`
- `FloatingEdge`, `GroupNode`, `InputNode`, `NodeSearch`, `NodeStatusIndicator`
- `NodeTooltip`, `OutputNode`, `SimpleBezierEdge`, `SmoothStepEdge`
- `StepEdge`, `StraightEdge`, `ToolbarNode`

### Changed

- **@xyflow/react** upgraded from 12.3.5 to 12.10.1 (7 minor versions, 33 releases, no breaking changes)
- **elkjs** upgraded from 0.8.2 to 0.9.3
- **zustand** upgraded from 4.4.7 to 4.5.5 (stays on 4.x — React Flow requires ^4.4.0)
- **React 18+ Compatibility**: Converted `DashFlows.defaultProps` to JavaScript default parameters to eliminate deprecation warnings
- **GroupNode Refactoring**: Simplified to React Fragment; styling applied directly to `.react-flow__node-group`
- **Node Base Styling**: Changed from fixed `width` to `max-width` for better layout flexibility

### Fixed

- **React defaultProps Warning**: Eliminated React 18+ deprecation warning across all components
- **GroupNode Nested Container**: Fixed nested wrapper div rendering issue
- CSS `!important` rules added to ensure layout class overrides apply correctly

---

## [1.1.0] - 2025-12-15

### Added

#### Custom Icons with DashIconify
- **DashIconify Integration**: InputNode, DefaultNode, and OutputNode now support custom icons via DashIconify
- **Icon Props**: New `icon` prop accepts DashIconify components or any Dash component for custom node icons
- **Icon Color**: New `iconColor` prop allows customizing the icon container background color
- **Show/Hide Icon**: New `showIcon` prop to toggle icon visibility (useful for text-only nodes)

#### Node Layout System
- **Layout Prop**: New `layout` prop with two options:
  - `"stacked"` (default): Vertical arrangement with icon above text
  - `"horizontal"`: Two[CHANGELOG.md](CHANGELOG.md)-column layout with icon on left, text on right
- **Content-Aware Sizing**: Nodes automatically adjust size based on content configuration:
  - **Icon-only nodes**: Compact sizing that fits the icon (no wasted space)
  - **Text-only nodes**: Centered text without reserved icon space
  - **Full content nodes**: Standard layout with icon and text

#### Enhanced Node Data Props
- **Title Prop**: New `title` prop as alias for `label` (for clarity when using body text)
- **Body Prop**: New `body` prop for description text below the title/sublabel
- **Sublabel Prop**: Secondary text displayed below the main label

#### New Example
- **Example 22**: `22_custom_icons.py` - Interactive demo showcasing:
  - Custom icons with DashIconify
  - Layout toggle (stacked/horizontal)
  - Show/hide icon toggle
  - Dynamic icon, title, and body text updates via DMC forms

### Changed

- **Node Base Styling**: Changed from fixed `width` to `max-width` for better layout flexibility
- **CSS Layout Classes**: Added `!important` flags to ensure layout overrides work correctly

### Fixed

- **Horizontal Layout**: Fixed horizontal layout not being applied correctly due to CSS specificity issues
- **Icon-Only Node Width**: Fixed icon-only nodes being too wide by using `inline-flex` display

#### New Examples
- **Example 23**: `23_callback_stress_test.py` - Stress test for Dash callbacks with DashFlows
  - 50 initial nodes in 5x10 grid
  - Multiple simultaneous callbacks monitoring events
  - Batch operations and live metrics
  - Event logging for debugging

### Changed

- **Node Base Styling**: Changed from fixed `width` to `max-width` for better layout flexibility
- **CSS Layout Classes**: Added `!important` flags to ensure layout overrides work correctly
- **React 18+ Compatibility**: Converted `DashFlows.defaultProps` to JavaScript default parameters to eliminate React deprecation warnings
- **GroupNode Simplification**: Refactored GroupNode to use React Fragment instead of wrapper divs, styling applied directly to `.react-flow__node-group`

### Fixed

- **Multi-select Not Working**: Added `multiSelectionKeyCode="Shift"` support in example 07
- **Right-click Context Menu**: Fixed context menu callback in example 07 to display node data
- **React defaultProps Warning**: Fixed React 18+ deprecation warning by using default parameters instead of `Component.defaultProps`
- **GroupNode Nested Container**: Fixed GroupNode appearing with nested container by simplifying component to render only NodeResizer and label badge
- **Viewport Control Buttons**: Fixed Pan to Top Left/Center/Bottom Right buttons in example 09 by correcting the `viewportAction` object structure
- **Dark Mode Toggle (DMC)**: Fixed Mantine dark mode toggle in example 11 using modern `data-mantine-color-scheme` attribute pattern

### Technical

- Added CSS classes for content detection: `.df-icon-only`, `.df-text-only`, `.df-full-content`
- Added CSS classes for layouts: `.df-layout-stacked`, `.df-layout-horizontal`
- Added horizontal layout structure with `.df-node-icon-column` and `.df-node-text-column`
- Added Iconify API fallback for rendering DashIconify icons when namespace unavailable
- Enhanced `renderDashComponent` function to handle serialized Dash component format from callbacks
- Added comprehensive JSDoc descriptions to all component propTypes for better IDE support
- GroupNode now styles `.react-flow__node-group` directly with `!important` rules for proper React Flow integration

---

## [1.0.0] - 2024-12-10

### Added

#### New Node Types
- **DefaultNode**: Standard node with fully configurable handles
- **InputNode**: Source node with green accent styling and output handles
- **OutputNode**: Sink node with purple accent styling and input handles
- **GroupNode**: Container node for visually grouping related nodes
- **ToolbarNode**: Node with floating toolbar that appears on selection
- **ResizableNode**: Node that users can resize with drag handles

#### New Edge Types
- **SimpleBezierEdge**: Smooth curved Bezier connections
- **SmoothStepEdge**: Rounded right-angle step connections
- **StepEdge**: Sharp right-angle step connections
- **StraightEdge**: Direct straight-line connections
- **AnimatedSvgEdge**: Edge with animated flowing dot effect
- **ButtonEdge**: Edge with interactive delete button
- **DataEdge**: Edge that displays data labels inline

#### New UI Components
- **NodeStatusIndicator**: Visual status wrapper for nodes with loading, success, and error states
- **NodeTooltip**: Hover tooltip component for nodes
- **NodeSearch**: Search and filter nodes in the flow
- **ButtonHandle**: Interactive button-styled handle component
- **DevTools**: Development debugging panel

#### Theming System
- **Glass Morphism Theme**: Default theme with blur effects and transparency
- **Solid Theme**: Opaque cards with traditional shadows
- **Minimal Theme**: Clean, border-focused minimal design
- **Color Schemes**: 6 color schemes (default, ocean, forest, sunset, midnight, rose)
- **Dark Mode Support**: Automatic dark mode via React Flow, Mantine, or custom classes
- **CSS Custom Properties**: Full theming via CSS variables for customization

#### Status Indicators
- Loading state with blue pulsing glow animation
- Success state with green border and checkmark badge
- Error state with red border, X badge, and shake animation
- Initial state (no visual indicator)

#### Handle Configuration
- Configurable handle positions (top, right, bottom, left)
- Multiple handles per position
- Custom handle IDs for precise edge connections
- Handle styling customization

#### Examples
- 21 comprehensive example files covering all features
- Basic usage to advanced implementations
- Context menus, drag-and-drop, copy/paste demonstrations
- ELK layout integration examples
- Dash component embedding examples

### Changed

- Upgraded to React Flow 12.3.5 (@xyflow/react)
- Complete rewrite of theming system with CSS custom properties
- Improved handle hover states with smooth transitions
- Enhanced node selection styling
- Better dark mode color contrast

### Fixed

- Node status indicator loading animation now works properly with rounded corners
- Fixed CSS class name mismatch between JavaScript components and stylesheets
- Improved glass effect rendering across browsers
- Fixed handle positioning for custom node types

### Technical

- Built on React 18.2
- Uses Zustand for state management
- ELK.js integration for automatic layouts
- html-to-image for flow export functionality
- Full TypeScript prop-types definitions

## [0.0.4] - 2024-11-XX

### Added
- Configurable handles feature
- Callback to view nodes for debugging
- DevTools component

### Changed
- Updated version number

## [0.0.3] - 2024-XX-XX

### Added
- Initial public release
- Basic DashFlows component
- React Flow integration
- AnimatedCircleNode
- AnimatedNodeEdge

---

## Migration Guide

### From 0.0.x to 1.0.0

1. **Update import statements**: Component names remain the same, but ensure you're importing from `dash_flows`:
   ```python
   from dash_flows import DashFlows, DefaultNode, InputNode, OutputNode
   ```

2. **Node status indicators**: The status indicator CSS classes have been updated. If you were using custom CSS targeting the old classes, update to:
   - `.df-node-status-loading-border` for loading state
   - `.df-node-status-success` for success state
   - `.df-node-status-error` for error state

3. **Theming**: The new theming system uses CSS custom properties. Apply theme classes to your container:
   ```python
   DashFlows(
       id='flow',
       className='df-theme-glass df-scheme-ocean',
       ...
   )
   ```

4. **Handle configuration**: Handles are now configured via the node's `data.handles` array instead of separate props.

---

<!-- /drag-drop — https://flows.2plot.dev/drag-drop/llms.txt -->

# Drag & Drop

> Drag nodes from a palette onto the canvas, and create nodes by dropping an edge.

---



### Overview

Build a node palette and drop new nodes onto the canvas (`droppedNode`), or drag a connection into empty space to spawn a connected node with `addNodeOnEdgeDrop` (`edgeDroppedNode`).

### 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/drag_drop/demo.py

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

nodes = [{'id': 'palette', 'type': 'input', 'data': {'label': 'Drag me'}, 'position': {'x': 60, 'y': 60}},
 {'id': 'canvas',
  'type': 'default',
  'data': {'label': 'Drop target'},
  'position': {'x': 240, 'y': 200}}]

edges = [{'id': 'pc', 'source': 'palette', 'target': 'canvas'}]

component = dash_flows.DashFlows(
    id="drag_drop-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=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>`.

#### Drag and Drop

Drag node templates from a sidebar palette and drop them onto the flow canvas. Use this pattern when you want a component-library-style builder where users assemble a graph from pre-defined node kinds.



```python
# File: examples/17_drag_and_drop.py

"""
Example 17: Drag and Drop from Sidebar
======================================
This example demonstrates how to:
- Create a sidebar with draggable node templates
- Drop nodes onto the flow canvas
- Create new nodes at the drop position
"""

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

app = dash.Dash(__name__)

# Sidebar styles
sidebar_style = {
    "width": "200px",
    "padding": "15px",
    "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
    "borderRadius": "12px",
    "marginRight": "15px",
}

draggable_item_style = {
    "padding": "12px",
    "marginBottom": "10px",
    "background": "rgba(255, 255, 255, 0.9)",
    "borderRadius": "8px",
    "cursor": "grab",
    "textAlign": "center",
    "fontWeight": "500",
    "boxShadow": "0 2px 8px rgba(0,0,0,0.15)",
    "transition": "transform 0.2s, box-shadow 0.2s",
}

# Initial nodes
initial_nodes = [
    {
        "id": "start",
        "type": "input",
        "data": {"label": "Start Here"},
        "position": {"x": 250, "y": 50},
    },
]

app.layout = html.Div([
    html.H1("Drag and Drop Example"),
    html.P("Drag node types from the sidebar and drop them onto the canvas."),

    html.Div([
        # Sidebar with draggable items
        html.Div([
            html.H4("Node Palette", style={"color": "white", "marginBottom": "15px"}),

            # Input node
            html.Div(
                "Input Node",
                id="drag-input",
                draggable="true",
                style=draggable_item_style,
                **{"data-node-type": "input", "data-node-label": "Input"}
            ),

            # Default node
            html.Div(
                "Process Node",
                id="drag-default",
                draggable="true",
                style=draggable_item_style,
                **{"data-node-type": "default", "data-node-label": "Process"}
            ),

            # Output node
            html.Div(
                "Output Node",
                id="drag-output",
                draggable="true",
                style=draggable_item_style,
                **{"data-node-type": "output", "data-node-label": "Output"}
            ),

            # Resizable node
            html.Div(
                "Resizable Node",
                id="drag-resizable",
                draggable="true",
                style=draggable_item_style,
                **{"data-node-type": "resizable", "data-node-label": "Resizable"}
            ),
        ], style=sidebar_style),

        # Flow canvas
        html.Div([
            dash_flows.DashFlows(
                id="flow",
                nodes=initial_nodes,
                edges=[],
                style={"height": "500px", "border": "2px dashed #ccc", "borderRadius": "8px"},
                fitView=True,
                showControls=True,
                showMiniMap=True,
                showBackground=True,
            ),
        ], style={"flex": "1"}),
    ], style={"display": "flex", "gap": "15px"}),

    # Status display
    html.Div(id="drop-status", style={
        "marginTop": "15px",
        "padding": "10px",
        "background": "#f0f0f0",
        "borderRadius": "5px",
    }),

    # Hidden client-side callback script
    html.Script('''
        document.addEventListener('DOMContentLoaded', function() {
            const draggables = document.querySelectorAll('[draggable="true"]');
            draggables.forEach(item => {
                item.addEventListener('dragstart', function(e) {
                    const nodeType = this.getAttribute('data-node-type');
                    const nodeLabel = this.getAttribute('data-node-label');
                    e.dataTransfer.setData('application/reactflow', JSON.stringify({
                        type: nodeType,
                        data: { label: nodeLabel }
                    }));
                    e.dataTransfer.effectAllowed = 'move';
                });
            });
        });
    '''),
])


# Client-side callback to handle drag start
app.clientside_callback(
    """
    function(n) {
        // Set up drag events for all draggable items
        const draggables = document.querySelectorAll('[draggable="true"]');
        draggables.forEach(item => {
            item.ondragstart = function(e) {
                const nodeType = this.getAttribute('data-node-type');
                const nodeLabel = this.getAttribute('data-node-label');
                e.dataTransfer.setData('application/reactflow', JSON.stringify({
                    type: nodeType,
                    data: { label: nodeLabel }
                }));
                e.dataTransfer.setData('text/plain', JSON.stringify({
                    type: nodeType,
                    data: { label: nodeLabel }
                }));
                e.dataTransfer.effectAllowed = 'move';
            };
        });
        return window.dash_clientside.no_update;
    }
    """,
    Output("drop-status", "data-init"),
    Input("drop-status", "id"),
)


# Handle dropped node - create new node
@callback(
    [Output("flow", "nodes"),
     Output("drop-status", "children")],
    Input("flow", "droppedNode"),
    State("flow", "nodes"),
    prevent_initial_call=True,
)
def handle_drop(dropped_node, current_nodes):
    if not dropped_node:
        return dash.no_update, dash.no_update

    # Generate unique ID for new node
    new_id = f"node-{str(uuid.uuid4())[:8]}"

    # Create new node
    new_node = {
        "id": new_id,
        "type": dropped_node.get("type", "default"),
        "data": dropped_node.get("data", {"label": "New Node"}),
        "position": dropped_node.get("position", {"x": 100, "y": 100}),
    }

    # Add resizable node specific properties
    if new_node["type"] == "resizable":
        new_node["data"]["handles"] = [
            {"id": "top", "type": "target", "position": "top"},
            {"id": "bottom", "type": "source", "position": "bottom"},
        ]
        new_node["style"] = {"width": 200, "height": 100}

    # Append to existing nodes
    updated_nodes = current_nodes + [new_node] if current_nodes else [new_node]

    status = f"Created new {new_node['type']} node '{new_node['data'].get('label', 'Node')}' at ({dropped_node['position']['x']:.0f}, {dropped_node['position']['y']:.0f})"

    return updated_nodes, status


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `draggable="true"` HTML divs in the sidebar carry `data-node-type` / `data-node-label` attributes describing what to create.
- A `clientside_callback` wires up `ondragstart` handlers that pack that data into `e.dataTransfer` as `application/reactflow` (and `text/plain`) JSON — this runs once on load via a dummy `Input("...", "id")` trigger.
- `droppedNode` (output prop) fires when the user releases the drag over the canvas, giving you the parsed `type`, `data`, and drop `position`.
- The `droppedNode` callback appends a new node dict to `nodes` — `type="resizable"` nodes additionally get custom `handles` and a `style` with width/height.
- The raw `droppedNode` payload is echoed as JSON below the canvas so you can see exactly what the drop event contains.

#### Add Node on Edge Drop

Drag a connection off a handle and release it on empty canvas to spawn a new, already-connected node — no palette required.



```python
# File: examples/28_add_node_on_drop.py

"""
Example 28: Add Node on Edge Drop
===================================
Demonstrates creating new nodes by dragging a connection from a handle
and dropping it on empty canvas. A new default node is created at the
drop position and automatically connected.
"""

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

app = dash.Dash(__name__)

initial_nodes = [
    {
        "id": "start",
        "type": "input",
        "data": {"label": "Start Here", "sublabel": "Drag from a handle"},
        "position": {"x": 250, "y": 50},
    },
]

initial_edges = []

app.layout = html.Div([
    html.H1("Add Node on Edge Drop"),
    html.P(
        "Drag from any handle and release on empty canvas to create a new node. "
        "The new node is automatically connected to the source."
    ),
    dash_flows.DashFlows(
        id="drop-flow",
        nodes=initial_nodes,
        edges=initial_edges,
        addNodeOnEdgeDrop=True,
        style={"height": "600px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showBackground=True,
        showMiniMap=True,
        backgroundVariant="dots",
        colorScheme="forest",
    ),
    html.Div([
        html.H3("Edge Drop Events", style={"marginTop": "16px"}),
        html.Pre(
            id="drop-info",
            style={
                "background": "#f5f5f5",
                "padding": "12px",
                "borderRadius": "6px",
                "fontSize": "12px",
                "maxHeight": "200px",
                "overflow": "auto",
            },
        ),
    ]),
    html.Div([
        html.H3("Node Connections"),
        html.Pre(
            id="connections-info",
            style={
                "background": "#f5f5f5",
                "padding": "12px",
                "borderRadius": "6px",
                "fontSize": "12px",
                "maxHeight": "200px",
                "overflow": "auto",
            },
        ),
    ]),
])


@callback(
    Output("drop-info", "children"),
    Input("drop-flow", "edgeDroppedNode"),
)
def show_drop_event(dropped):
    if not dropped:
        return "Drag from a handle and drop on empty canvas..."
    return json.dumps(dropped, indent=2)


@callback(
    Output("connections-info", "children"),
    Input("drop-flow", "nodeConnections"),
)
def show_connections(connections):
    if not connections:
        return "No connections yet."
    return json.dumps(connections, indent=2)


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `addNodeOnEdgeDrop=True` tells DashFlows to create a node automatically whenever a connection drag ends over empty canvas instead of over a handle.
- `edgeDroppedNode` (output prop) reports the new node/edge pair that was created, including the drop position and which node it was connected from.
- `nodeConnections` (output prop) is a live map of every node's incoming/outgoing edges, updated as connections are made — useful for building things like validation or dependency graphs.
- Both outputs are dumped as formatted JSON under the canvas so you can watch the events fire as you drag.
- `backgroundVariant="dots"` and `colorScheme="forest"` are purely cosmetic and can be swapped for any of the supported presets.


---

*Source: /drag-drop*

---

<!-- /edges — https://flows.2plot.dev/edges/llms.txt -->

# Edges

> Every edge type, including floating edges that attach to the nearest border.

---



### Overview

Set an edge's `type` to choose its path: `straight`, `step`, `smoothstep`, `simplebezier`, `button`, `data`, `animated`, or `floating`. Floating edges connect to the nearest point on each node's border instead of a fixed handle.

### 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/edges/demo.py

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

nodes = [{'id': '0', 'type': 'default', 'data': {'label': 'straight'}, 'position': {'x': 60, 'y': 40}},
 {'id': '1', 'type': 'default', 'data': {'label': 'step'}, 'position': {'x': 320, 'y': 40}},
 {'id': '2', 'type': 'default', 'data': {'label': 'smoothstep'}, 'position': {'x': 60, 'y': 160}},
 {'id': '3',
  'type': 'default',
  'data': {'label': 'simplebezier'},
  'position': {'x': 320, 'y': 160}},
 {'id': '4', 'type': 'default', 'data': {'label': 'button'}, 'position': {'x': 60, 'y': 280}},
 {'id': '5', 'type': 'default', 'data': {'label': 'animated'}, 'position': {'x': 320, 'y': 280}}]

edges = [{'id': 's', 'source': '0', 'target': '1', 'type': 'straight'},
 {'id': 'st', 'source': '1', 'target': '2', 'type': 'step'},
 {'id': 'sm', 'source': '2', 'target': '3', 'type': 'smoothstep'},
 {'id': 'sb', 'source': '3', 'target': '4', 'type': 'simplebezier'},
 {'id': 'bt', 'source': '4', 'target': '5', 'type': 'button'},
 {'id': 'an', 'source': '5', 'target': '0', 'type': 'animated'}]

component = dash_flows.DashFlows(
    id="edges-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=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>`.

#### All Edge Types

A gallery of every edge type — default, straight, step, smoothstep, simplebezier, and button — laid out as a row of sources connecting to a row of targets, plus a callback that tracks edge deletions.



```python
# File: examples/03_all_edge_types.py

"""
Example 03: All Edge Types
==========================
This example showcases all available edge types in DashFlows:
- default: Standard bezier curve edge (React Flow built-in)
- straight: Direct line between nodes
- step: Right-angled connections with sharp corners
- smoothstep: Right-angled with rounded corners
- simplebezier: Simple bezier curve
- button: Edge with interactive button (for deletion)
- animated: Edge with Dash component animation
"""

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

app = dash.Dash(__name__)

# Create a row of source nodes and a row of target nodes
nodes = [
    # Source nodes (top row)
    {"id": "s1", "type": "input", "data": {"label": "Default"}, "position": {"x": 50, "y": 50}},
    {"id": "s2", "type": "input", "data": {"label": "Straight"}, "position": {"x": 200, "y": 50}},
    {"id": "s3", "type": "input", "data": {"label": "Step"}, "position": {"x": 350, "y": 50}},
    {"id": "s4", "type": "input", "data": {"label": "SmoothStep"}, "position": {"x": 500, "y": 50}},
    {"id": "s5", "type": "input", "data": {"label": "SimpleBezier"}, "position": {"x": 650, "y": 50}},
    {"id": "s6", "type": "input", "data": {"label": "Button Edge"}, "position": {"x": 800, "y": 50}},

    # Target nodes (bottom row)
    {"id": "t1", "type": "output", "data": {"label": "Target 1"}, "position": {"x": 50, "y": 300}},
    {"id": "t2", "type": "output", "data": {"label": "Target 2"}, "position": {"x": 200, "y": 300}},
    {"id": "t3", "type": "output", "data": {"label": "Target 3"}, "position": {"x": 350, "y": 300}},
    {"id": "t4", "type": "output", "data": {"label": "Target 4"}, "position": {"x": 500, "y": 300}},
    {"id": "t5", "type": "output", "data": {"label": "Target 5"}, "position": {"x": 650, "y": 300}},
    {"id": "t6", "type": "output", "data": {"label": "Target 6"}, "position": {"x": 800, "y": 300}},
]

edges = [
    # Default edge (bezier curve) - React Flow's built-in
    {
        "id": "e1",
        "source": "s1",
        "target": "t1",
        "type": "default",
        "label": "Default",
        "animated": True,
    },

    # Straight edge - direct line
    {
        "id": "e2",
        "source": "s2",
        "target": "t2",
        "type": "straight",
        "label": "Straight",
    },

    # Step edge - sharp right angles
    {
        "id": "e3",
        "source": "s3",
        "target": "t3",
        "type": "step",
        "label": "Step",
    },

    # SmoothStep edge - rounded right angles
    {
        "id": "e4",
        "source": "s4",
        "target": "t4",
        "type": "smoothstep",
        "label": "SmoothStep",
        "data": {"borderRadius": 15},  # Custom corner radius
    },

    # SimpleBezier edge - simple curve
    {
        "id": "e5",
        "source": "s5",
        "target": "t5",
        "type": "simplebezier",
        "label": "SimpleBezier",
    },

    # Button edge - with delete button
    {
        "id": "e6",
        "source": "s6",
        "target": "t6",
        "type": "button",
        "data": {
            "label": "Click X to delete",
            "showButton": True,
            "buttonLabel": "x",
        },
    },
]

app.layout = html.Div([
    html.H1("All Edge Types Example"),
    html.P("Demonstrates every available edge type in DashFlows."),
    html.Ul([
        html.Li("Default: Standard bezier curve (animated in this example)"),
        html.Li("Straight: Direct line connection"),
        html.Li("Step: Right-angled path with sharp corners"),
        html.Li("SmoothStep: Right-angled with configurable rounded corners"),
        html.Li("SimpleBezier: Simple curved line"),
        html.Li("Button: Edge with interactive delete button"),
    ]),
    dash_flows.DashFlows(
        id="edge-types-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
    ),
    html.Div(id="edge-deleted-info", style={"marginTop": "20px"}),
])

# Track edge deletions
@callback(
    Output("edge-deleted-info", "children"),
    Input("edge-types-flow", "edges"),
    prevent_initial_call=True,
)
def track_edge_changes(current_edges):
    if current_edges is None:
        return ""
    edge_count = len(current_edges)
    return f"Current edge count: {edge_count}"

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `type` on each edge — picks the renderer (`"default"`, `"straight"`, `"step"`, `"smoothstep"`, `"simplebezier"`, `"button"`); omit it to fall back to React Flow's default bezier curve.
- `animated=True` — draws a moving dashed line on the default edge.
- `data={"borderRadius": 15}` — the smoothstep edge reads this to control corner rounding.
- `data={"showButton": True, "buttonLabel": "x"}` — the button edge renders a small button (here used for deletion) on top of the path.
- `Input("ex03-edge-types-flow", "edges")` — fires whenever the edge list changes (e.g. after clicking a button edge's delete control), letting you track the current edge count.

#### Floating Edges

Edges that dynamically attach to the closest border point of each node, instead of a fixed handle position — useful when nodes sit at irregular angles relative to each other.



```python
# File: examples/26_floating_edges.py

"""
Example 26: Floating Edges
===========================
Demonstrates the 'floating' edge type which connects to the nearest point
on each node's border instead of fixed handle positions. This creates more
natural-looking connections, especially when nodes are positioned at angles.
"""

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

app = dash.Dash(__name__)

nodes = [
    {
        "id": "1",
        "type": "input",
        "data": {"label": "Data Source", "sublabel": "REST API"},
        "position": {"x": 0, "y": 0},
    },
    {
        "id": "2",
        "type": "default",
        "data": {"label": "Validate"},
        "position": {"x": 300, "y": -50},
    },
    {
        "id": "3",
        "type": "default",
        "data": {"label": "Transform"},
        "position": {"x": 150, "y": 200},
    },
    {
        "id": "4",
        "type": "default",
        "data": {"label": "Enrich"},
        "position": {"x": 450, "y": 150},
    },
    {
        "id": "5",
        "type": "output",
        "data": {"label": "Database", "sublabel": "PostgreSQL"},
        "position": {"x": 350, "y": 350},
    },
    {
        "id": "6",
        "type": "output",
        "data": {"label": "Dashboard", "sublabel": "Visualization"},
        "position": {"x": 50, "y": 400},
    },
]

# All edges use the 'floating' type - they connect to the nearest border point
edges = [
    {"id": "e1-2", "source": "1", "target": "2", "type": "floating", "label": "raw"},
    {"id": "e1-3", "source": "1", "target": "3", "type": "floating"},
    {"id": "e2-4", "source": "2", "target": "4", "type": "floating", "label": "valid"},
    {"id": "e3-4", "source": "3", "target": "4", "type": "floating"},
    {"id": "e3-6", "source": "3", "target": "6", "type": "floating"},
    {"id": "e4-5", "source": "4", "target": "5", "type": "floating", "label": "enriched"},
]

app.layout = html.Div([
    html.H1("Floating Edges"),
    html.P(
        "Floating edges connect to the nearest point on each node's border. "
        "Drag nodes around and watch the edges dynamically reattach to the closest point."
    ),
    dash_flows.DashFlows(
        id="floating-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "600px", "border": "1px solid #ddd"},
        fitView=True,
        fitViewOptions={"padding": 0.2},
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        colorScheme="ocean",
    ),
    html.Div(id="floating-info", style={"marginTop": "12px", "fontSize": "14px", "color": "#666"}),
])


@callback(
    Output("floating-info", "children"),
    Input("floating-flow", "clickedNode"),
)
def show_clicked(node):
    if not node:
        return "Click a node to see its info. Drag nodes to see floating edges update."
    return f"Clicked: {node['id']} - {node['data'].get('label', '')}"


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `type: "floating"` on every edge — connects to whichever point on the node's border is closest to the other node, and re-computes it live as nodes move.
- `fitViewOptions={"padding": 0.2}` — adds breathing room around the graph when `fitView` runs.
- `colorScheme="ocean"` — swaps the default glass theme palette for the built-in ocean color scheme.
- `showMiniMap` / `showBackground` — enables the minimap overlay and canvas background grid alongside the controls.
- `Input("ex26-floating-flow", "clickedNode")` — reports the clicked node's `id` and `data.label`; try dragging nodes to see the floating edges re-attach in real time.


---

*Source: /edges*

---

<!-- /getting-started — https://flows.2plot.dev/getting-started/llms.txt -->

# Getting Started

> Build your first dash-flows graph: nodes, edges, and styling.

---



### Overview

**dash-flows** renders interactive node graphs in Plotly Dash using [React Flow 12](https://reactflow.dev). A flow needs three things: a list of `nodes`, a list of `edges`, and a `style` with a `height`. Node `type` picks the visual (`input`, `default`, `output`, …); edges connect nodes by `source` and `target` id. Set `animated=True` on an edge for a moving dashed line.

### 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/getting_started/demo.py

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

nodes = [{'id': '1', 'type': 'input', 'data': {'label': 'Start'}, 'position': {'x': 120, 'y': 40}},
 {'id': '2', 'type': 'default', 'data': {'label': 'Process A'}, 'position': {'x': 40, 'y': 180}},
 {'id': '3', 'type': 'default', 'data': {'label': 'Process B'}, 'position': {'x': 220, 'y': 180}},
 {'id': '4', 'type': 'output', 'data': {'label': 'End'}, 'position': {'x': 120, 'y': 320}}]

edges = [{'id': 'e1-2', 'source': '1', 'target': '2', 'animated': True},
 {'id': 'e1-3', 'source': '1', 'target': '3'},
 {'id': 'e2-4', 'source': '2', 'target': '4'},
 {'id': 'e3-4', 'source': '3', 'target': '4', 'animated': True}]

component = dash_flows.DashFlows(
    id="getting_started-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=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>`.

#### Basic Nodes and Edges

The fundamentals — creating typed nodes, connecting them with edges, and basic positioning and styling. Use this pattern as the starting point for any new flow.



```python
# File: examples/01_basic_nodes_and_edges.py

"""
Example 01: Basic Nodes and Edges
=================================
This example demonstrates the fundamental building blocks of DashFlows:
- Creating nodes with different types
- Connecting nodes with edges
- Basic styling and positioning
"""

import dash
from dash import html
import dash_flows

app = dash.Dash(__name__)

# Define basic nodes
nodes = [
    {
        "id": "node-1",
        "type": "default",
        "data": {"label": "Start Node"},
        "position": {"x": 100, "y": 100},
    },
    {
        "id": "node-2",
        "type": "default",
        "data": {"label": "Process A"},
        "position": {"x": 100, "y": 250},
    },
    {
        "id": "node-3",
        "type": "default",
        "data": {"label": "Process B"},
        "position": {"x": 300, "y": 250},
    },
    {
        "id": "node-4",
        "type": "default",
        "data": {"label": "End Node"},
        "position": {"x": 200, "y": 400},
    },
]

# Define edges connecting the nodes
edges = [
    {
        "id": "edge-1-2",
        "source": "node-1",
        "target": "node-2",
        "animated": True,  # Shows animated dashed line
    },
    {
        "id": "edge-1-3",
        "source": "node-1",
        "target": "node-3",
    },
    {
        "id": "edge-2-4",
        "source": "node-2",
        "target": "node-4",
        "label": "Step 1",  # Edge with label
    },
    {
        "id": "edge-3-4",
        "source": "node-3",
        "target": "node-4",
        "label": "Step 2",
    },
]

app.layout = html.Div([
    html.H1("Basic Nodes and Edges Example"),
    html.P("This shows the fundamental elements of a flow diagram."),
    dash_flows.DashFlows(
        id="basic-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "600px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
    ),
])

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `nodes` — a plain list of dicts, each with an `id`, `type` (`"default"` here), `data.label`, and a `position` in canvas pixels.
- `edges` — connect nodes by `source`/`target` id; the `id` on each edge just needs to be unique.
- `animated=True` on an edge draws a moving dashed line, useful for highlighting active/primary paths.
- `label` on an edge renders text along the connector (see `"Step 1"` / `"Step 2"`).
- `style={"height": ...}` is required — React Flow needs an explicit container height to size the canvas.
- `fitView`, `showControls`, and `showMiniMap` are convenience toggles for the zoom/pan controls and minimap overlay.


---

*Source: /getting-started*

---

<!-- /interactions — https://flows.2plot.dev/interactions/llms.txt -->

# Interactions & Selection

> Clicks, context menus, connection validation and limits, and multi-select.

---



### Overview

dash-flows surfaces user interaction as callback props: `clickedNode`, `doubleClickedNode`, `hoveredNode`, `contextMenuNode`, `selectedNodes`, `selectedEdges`, and `lastConnection`. You can validate or cap connections before they are committed.

### 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/interactions/demo.py

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

nodes = [{'id': 'q', 'type': 'input', 'data': {'label': 'Request'}, 'position': {'x': 60, 'y': 40}},
 {'id': 'v', 'type': 'default', 'data': {'label': 'Validate'}, 'position': {'x': 60, 'y': 170}},
 {'id': 'p', 'type': 'default', 'data': {'label': 'Persist'}, 'position': {'x': 240, 'y': 170}},
 {'id': 'r', 'type': 'output', 'data': {'label': 'Respond'}, 'position': {'x': 150, 'y': 300}}]

edges = [{'id': 'qv', 'source': 'q', 'target': 'v'},
 {'id': 'vp', 'source': 'v', 'target': 'p'},
 {'id': 'pr', 'source': 'p', 'target': 'r'},
 {'id': 'vr', 'source': 'v', 'target': 'r'}]

component = dash_flows.DashFlows(
    id="interactions-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=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>`.

#### Node Interactions

Read click, drag, selection, and right-click (context-menu) events on nodes in real time — useful whenever the rest of your UI needs to react to what the user is doing on the canvas.



```python
# File: examples/07_node_interactions.py

"""
Example 07: Node Interactions
=============================
This example demonstrates node interaction callbacks:
- Node click events
- Node drag events
- Node hover events (via CSS)
- Context menu (right-click) events
- Selection change events
"""

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__, suppress_callback_exceptions=True)

initial_nodes = [
    {"id": "1", "type": "default", "data": {"label": "Click Me"}, "position": {"x": 100, "y": 50}},
    {"id": "2", "type": "default", "data": {"label": "Drag Me"}, "position": {"x": 300, "y": 50}},
    {"id": "3", "type": "default", "data": {"label": "Right-Click Me"}, "position": {"x": 500, "y": 50}},
    {"id": "4", "type": "default", "data": {"label": "Node 4"}, "position": {"x": 100, "y": 200}},
    {"id": "5", "type": "default", "data": {"label": "Node 5"}, "position": {"x": 300, "y": 200}},
    {"id": "6", "type": "default", "data": {"label": "Node 6"}, "position": {"x": 500, "y": 200}},
]

initial_edges = [
    {"id": "e1-4", "source": "1", "target": "4"},
    {"id": "e2-5", "source": "2", "target": "5"},
    {"id": "e3-6", "source": "3", "target": "6"},
]

app.layout = dmc.MantineProvider([
    html.H1("Node Interactions Example"),
    html.P("Interact with nodes and observe the callbacks."),

    dmc.Grid([
        dmc.GridCol([
            dash_flows.DashFlows(
                id="interaction-flow",
                nodes=initial_nodes,
                edges=initial_edges,
                style={"height": "400px", "border": "1px solid #ddd"},
                fitView=True,
                showControls=True,
                # Enable interactions
                nodesDraggable=True,
                nodesConnectable=True,
                elementsSelectable=True,
                # Enable multi-select with Shift key
                multiSelectionKeyCode="Shift",
            ),
        ], span=8),

        dmc.GridCol([
            dmc.Stack([
                dmc.Paper([
                    dmc.Text("Last Click Event:", fw=600),
                    html.Pre(id="click-event", children="Click a node...",
                             style={"fontSize": "11px", "maxHeight": "100px", "overflow": "auto"}),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Node Positions:", fw=600),
                    html.Pre(id="position-info", children="Drag a node...",
                             style={"fontSize": "11px", "maxHeight": "150px", "overflow": "auto"}),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Selection Info:", fw=600),
                    html.Pre(id="selection-info", children="Select nodes...",
                             style={"fontSize": "11px", "maxHeight": "100px", "overflow": "auto"}),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Context Menu (Right-Click):", fw=600),
                    html.Pre(id="context-menu-info", children="Right-click a node...",
                             style={"fontSize": "11px", "maxHeight": "100px", "overflow": "auto"}),
                ], p="sm", withBorder=True),
            ], gap="sm"),
        ], span=4),
    ]),

    dmc.Space(h=20),
    dmc.Text("Tips:", fw=600),
    dmc.List([
        dmc.ListItem("Click on any node to see click event data"),
        dmc.ListItem("Drag nodes to see position updates"),
        dmc.ListItem("Hold Shift and click to multi-select nodes"),
        dmc.ListItem("Use the selection box (drag on canvas) to select multiple nodes"),
        dmc.ListItem("Right-click a node to see context menu event data"),
    ]),
])

@callback(
    Output("click-event", "children"),
    Input("interaction-flow", "selectedNodes"),
    prevent_initial_call=True,
)
def on_node_click(selected_nodes):
    if not selected_nodes:
        return "No nodes selected"
    # Show the last selected node
    return json.dumps(selected_nodes, indent=2)

@callback(
    Output("position-info", "children"),
    Input("interaction-flow", "nodes"),
    prevent_initial_call=True,
)
def on_node_drag(nodes):
    if not nodes:
        return "No nodes"
    positions = {n["id"]: n["position"] for n in nodes}
    return json.dumps(positions, indent=2)

def extract_ids(items):
    """Extract IDs from selection data, handling different formats."""
    if not items:
        return []
    result = []
    for item in items:
        if isinstance(item, dict) and "id" in item:
            result.append(item["id"])
        elif isinstance(item, str):
            result.append(item)
    return result


@callback(
    Output("selection-info", "children"),
    Input("interaction-flow", "selectedNodes"),
    Input("interaction-flow", "selectedEdges"),
)
def on_selection_change(selected_nodes, selected_edges):
    info = {
        "selectedNodes": extract_ids(selected_nodes),
        "selectedEdges": extract_ids(selected_edges),
    }
    return json.dumps(info, indent=2)


@callback(
    Output("context-menu-info", "children"),
    Input("interaction-flow", "contextMenuNode"),
    prevent_initial_call=True,
)
def on_context_menu(context_menu_node):
    if not context_menu_node:
        return "Right-click a node..."
    # Show context menu event data
    return json.dumps(context_menu_node, indent=2)


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `Input("ex07-flow", "selectedNodes")` — fires whenever the node selection changes; the payload is the list of currently-selected node objects.
- `Input("ex07-flow", "nodes")` — fires on every node change, including drags, so you can read live `position` data.
- `Input("ex07-flow", "selectedEdges")` — pairs with `selectedNodes` to report the full current selection (nodes and edges together).
- `Input("ex07-flow", "contextMenuNode")` — fires when a node is right-clicked, with the node's `id` and `data` in the payload.
- `multiSelectionKeyCode="Shift"` — lets Shift+click add nodes to the current selection instead of replacing it.

#### Connection Validation

Reject invalid connections before they are created — self-connections and duplicate edges are blocked automatically, and source/target-only node types (`input` / `output`) restrict which end of a connection they can be.



```python
# File: examples/08_connection_validation.py

"""
Example 08: Connection Validation and Rules
===========================================
This example demonstrates connection validation:
- Preventing self-connections
- Limiting connections per handle
- Custom connection rules
- Visual feedback during connection
"""

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

app = dash.Dash(__name__)

# Define nodes with specific connection rules
nodes = [
    # Input nodes (sources only)
    {"id": "input-1", "type": "input", "data": {"label": "Data Source A"}, "position": {"x": 50, "y": 50}},
    {"id": "input-2", "type": "input", "data": {"label": "Data Source B"}, "position": {"x": 50, "y": 150}},

    # Processing nodes (can connect to each other)
    {"id": "process-1", "type": "default", "data": {"label": "Filter"}, "position": {"x": 250, "y": 50}},
    {"id": "process-2", "type": "default", "data": {"label": "Transform"}, "position": {"x": 250, "y": 150}},
    {"id": "process-3", "type": "default", "data": {"label": "Aggregate"}, "position": {"x": 450, "y": 100}},

    # Output nodes (targets only)
    {"id": "output-1", "type": "output", "data": {"label": "Database"}, "position": {"x": 650, "y": 50}},
    {"id": "output-2", "type": "output", "data": {"label": "API"}, "position": {"x": 650, "y": 150}},
]

initial_edges = [
    {"id": "e1", "source": "input-1", "target": "process-1"},
    {"id": "e2", "source": "input-2", "target": "process-2"},
]

app.layout = dmc.MantineProvider([
    html.H1("Connection Validation Example"),
    html.P("Try connecting nodes and observe the validation rules in action."),

    dmc.Alert([
        dmc.Text("Connection Rules:", fw=600),
        dmc.List([
            dmc.ListItem("Input nodes can only be sources (green)"),
            dmc.ListItem("Output nodes can only be targets (purple)"),
            dmc.ListItem("Self-connections are not allowed"),
            dmc.ListItem("Duplicate connections are prevented"),
        ], size="sm"),
    ], color="blue", variant="light", style={"marginBottom": 20}),

    dash_flows.DashFlows(
        id="validation-flow",
        nodes=nodes,
        edges=initial_edges,
        style={"height": "400px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        # Connection settings
        nodesConnectable=True,
        connectionMode="loose",  # Allow connections to any point on node
        # Visual feedback
        connectionLineStyle={"stroke": "#3b82f6", "strokeWidth": 2},
    ),

    dmc.Space(h=20),

    dmc.Paper([
        dmc.Text("Current Edges:", fw=600),
        html.Pre(id="edges-display", style={"fontSize": "11px", "maxHeight": "200px", "overflow": "auto"}),
    ], p="md", withBorder=True),

    dmc.Space(h=10),

    dmc.Group([
        dmc.Button("Add Random Connection", id="add-edge-btn", variant="outline"),
        dmc.Button("Clear All Edges", id="clear-edges-btn", variant="outline", color="red"),
    ]),
])

@callback(
    Output("edges-display", "children"),
    Input("validation-flow", "edges"),
)
def display_edges(edges):
    if not edges:
        return "No edges"
    edge_info = [{"id": e["id"], "source": e["source"], "target": e["target"]} for e in edges]
    return json.dumps(edge_info, indent=2)

@callback(
    Output("validation-flow", "edges", allow_duplicate=True),
    Input("clear-edges-btn", "n_clicks"),
    prevent_initial_call=True,
)
def clear_edges(n):
    return []

@callback(
    Output("validation-flow", "edges", allow_duplicate=True),
    Input("add-edge-btn", "n_clicks"),
    Input("validation-flow", "edges"),
    prevent_initial_call=True,
)
def add_random_edge(n_clicks, current_edges):
    import random
    from dash import ctx

    if ctx.triggered_id != "add-edge-btn":
        return current_edges

    # Try to create a valid random connection
    sources = ["input-1", "input-2", "process-1", "process-2", "process-3"]
    targets = ["process-1", "process-2", "process-3", "output-1", "output-2"]

    for _ in range(10):  # Try up to 10 times to find a valid connection
        source = random.choice(sources)
        target = random.choice(targets)

        if source == target:
            continue

        # Check if edge already exists
        edge_id = f"e-{source}-{target}"
        if any(e["id"] == edge_id or (e["source"] == source and e["target"] == target) for e in current_edges):
            continue

        # Valid new edge
        new_edge = {"id": edge_id, "source": source, "target": target}
        return current_edges + [new_edge]

    return current_edges  # No valid connection found

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `type="input"` / `type="output"` on nodes — input nodes expose only a source handle, output nodes only a target handle, so invalid directions are impossible to draw.
- `connectionMode="loose"` — allows dragging a connection from or to any point on a node, not just a designated handle.
- `connectionLineStyle` — customizes the dashed preview line shown while a connection is being dragged.
- The "Add Random Connection" button retries up to 10 random source/target pairs and skips any that would be a self-connection or a duplicate of an existing edge id.
- `Input("ex08-flow", "edges")` — reads back the current edge list after every add/remove so the JSON panel always reflects the live graph.

#### Selection and Multi-select

Select nodes and edges by click, Shift+click, or drag-box, and drive selection programmatically from buttons — handy for bulk actions like "select all" or "delete selected".



```python
# File: examples/10_selection_multiselect.py

"""
Example 10: Selection and Multi-Select
======================================
This example demonstrates selection features:
- Single node selection
- Multi-node selection (Shift+click or drag selection)
- Edge selection
- Selection callbacks
- Programmatic selection
"""

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

app = dash.Dash(__name__, suppress_callback_exceptions=True)


def extract_id(item):
    """Extract ID from an item, handling different formats."""
    if isinstance(item, dict) and "id" in item:
        return item["id"]
    elif isinstance(item, str):
        return item
    return None


def extract_ids(items):
    """Extract IDs from selection data, handling different formats."""
    if not items:
        return []
    return [id for id in (extract_id(item) for item in items) if id is not None]

initial_nodes = [
    {"id": "a", "type": "default", "data": {"label": "Node A"}, "position": {"x": 100, "y": 50}},
    {"id": "b", "type": "default", "data": {"label": "Node B"}, "position": {"x": 300, "y": 50}},
    {"id": "c", "type": "default", "data": {"label": "Node C"}, "position": {"x": 500, "y": 50}},
    {"id": "d", "type": "default", "data": {"label": "Node D"}, "position": {"x": 100, "y": 200}},
    {"id": "e", "type": "default", "data": {"label": "Node E"}, "position": {"x": 300, "y": 200}},
    {"id": "f", "type": "default", "data": {"label": "Node F"}, "position": {"x": 500, "y": 200}},
]

initial_edges = [
    {"id": "e-ab", "source": "a", "target": "b"},
    {"id": "e-bc", "source": "b", "target": "c"},
    {"id": "e-ad", "source": "a", "target": "d"},
    {"id": "e-be", "source": "b", "target": "e"},
    {"id": "e-cf", "source": "c", "target": "f"},
    {"id": "e-de", "source": "d", "target": "e"},
    {"id": "e-ef", "source": "e", "target": "f"},
]

app.layout = dmc.MantineProvider([
    html.H1("Selection and Multi-Select Example"),
    html.P("Select nodes and edges using various methods."),

    dmc.Alert([
        dmc.Text("Selection Tips:", fw=600),
        dmc.List([
            dmc.ListItem("Click a node/edge to select it"),
            dmc.ListItem("Shift+click to add to selection"),
            dmc.ListItem("Drag on canvas to create selection box"),
            dmc.ListItem("Ctrl+A to select all"),
            dmc.ListItem("Escape to deselect all"),
        ], size="sm"),
    ], color="blue", variant="light", style={"marginBottom": 20}),

    dmc.Grid([
        dmc.GridCol([
            dash_flows.DashFlows(
                id="selection-flow",
                nodes=initial_nodes,
                edges=initial_edges,
                style={"height": "400px", "border": "1px solid #ddd"},
                fitView=True,
                showControls=True,
                # Selection settings
                elementsSelectable=True,
                selectNodesOnDrag=False,  # Only select on click, not drag
                selectionOnDrag=True,  # Allow box selection
                selectionMode="partial",  # Select nodes partially in box
                multiSelectionKeyCode="Shift",
            ),
        ], span=8),

        dmc.GridCol([
            dmc.Stack([
                dmc.Paper([
                    dmc.Text("Selected Nodes:", fw=600),
                    html.Div(id="selected-nodes-display"),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Selected Edges:", fw=600),
                    html.Div(id="selected-edges-display"),
                ], p="sm", withBorder=True),

                dmc.Paper([
                    dmc.Text("Selection Actions:", fw=600),
                    dmc.Stack([
                        dmc.Button("Select All Nodes", id="btn-select-all", fullWidth=True, size="sm"),
                        dmc.Button("Select Odd Nodes", id="btn-select-odd", fullWidth=True, size="sm", variant="outline"),
                        dmc.Button("Clear Selection", id="btn-clear-selection", fullWidth=True, size="sm", variant="outline", color="red"),
                        dmc.Button("Delete Selected", id="btn-delete-selected", fullWidth=True, size="sm", color="red"),
                    ], gap="xs"),
                ], p="sm", withBorder=True),
            ], gap="sm"),
        ], span=4),
    ]),
])

@callback(
    Output("selected-nodes-display", "children"),
    Input("selection-flow", "selectedNodes"),
)
def display_selected_nodes(selected_nodes):
    if not selected_nodes:
        return dmc.Text("No nodes selected", c="dimmed", size="sm")
    node_ids = extract_ids(selected_nodes)
    return dmc.Group([
        dmc.Badge(nid, variant="filled", size="sm") for nid in node_ids
    ], gap="xs")

@callback(
    Output("selected-edges-display", "children"),
    Input("selection-flow", "selectedEdges"),
)
def display_selected_edges(selected_edges):
    if not selected_edges:
        return dmc.Text("No edges selected", c="dimmed", size="sm")
    edge_ids = extract_ids(selected_edges)
    return dmc.Group([
        dmc.Badge(eid, variant="outline", size="sm") for eid in edge_ids
    ], gap="xs")

@callback(
    Output("selection-flow", "nodes"),
    Output("selection-flow", "edges"),
    Input("btn-select-all", "n_clicks"),
    Input("btn-select-odd", "n_clicks"),
    Input("btn-clear-selection", "n_clicks"),
    Input("btn-delete-selected", "n_clicks"),
    State("selection-flow", "nodes"),
    State("selection-flow", "edges"),
    State("selection-flow", "selectedNodes"),
    State("selection-flow", "selectedEdges"),
    prevent_initial_call=True,
)
def handle_selection_actions(all_clicks, odd_clicks, clear_clicks, delete_clicks,
                             nodes, edges, selected_nodes, selected_edges):
    if not nodes:
        return initial_nodes, initial_edges

    triggered = ctx.triggered_id

    if triggered == "btn-select-all":
        # Mark all nodes as selected
        updated_nodes = [{**n, "selected": True} for n in nodes]
        return updated_nodes, edges

    elif triggered == "btn-select-odd":
        # Select nodes with odd indices
        updated_nodes = []
        for i, n in enumerate(nodes):
            updated_nodes.append({**n, "selected": (i % 2 == 0)})
        return updated_nodes, edges

    elif triggered == "btn-clear-selection":
        # Clear all selections
        updated_nodes = [{**n, "selected": False} for n in nodes]
        updated_edges = [{**e, "selected": False} for e in edges]
        return updated_nodes, updated_edges

    elif triggered == "btn-delete-selected":
        # Remove selected nodes and their connected edges
        if not selected_nodes:
            return nodes, edges
        selected_ids = set(extract_ids(selected_nodes))
        remaining_nodes = [n for n in nodes if n["id"] not in selected_ids]
        remaining_edges = [e for e in edges if e["source"] not in selected_ids and e["target"] not in selected_ids]
        return remaining_nodes, remaining_edges

    return nodes, edges

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `selectionOnDrag=True` with `selectNodesOnDrag=False` — dragging on empty canvas draws a selection box instead of moving nodes; `selectionMode="partial"` selects any node the box merely touches.
- `Input("ex10-flow", "selectedNodes")` / `Input("ex10-flow", "selectedEdges")` — read the live selection to render badges for each selected id.
- Setting `"selected": True` on node/edge dicts and writing them back to `Output("ex10-flow", "nodes")` / `"edges"` — the supported way to drive selection programmatically (e.g. "Select All", "Select Odd Nodes").
- "Delete Selected" filters `selectedNodes` out of the node list, then also drops any edge whose `source` or `target` pointed at a deleted node.
- `multiSelectionKeyCode="Shift"` — Shift+click adds to the selection rather than replacing it.

#### Connection Limits

Cap how many edges a node will accept, either per-direction or in total, so a flow can enforce structural rules like "this input only takes one wire".



```python
# File: examples/16_connection_limits.py

"""
Example 16: Connection Limits
=============================
This example demonstrates how to limit the number of connections:
- maxConnections: Total connections (source + target) for a node
- maxSourceConnections: Max outgoing connections from a node
- maxTargetConnections: Max incoming connections to a node
"""

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

app = dash.Dash(__name__)

# Nodes with connection limits
nodes = [
    # This node can only have 1 outgoing connection
    {
        "id": "single-out",
        "type": "input",
        "data": {
            "label": "Single Output (max 1)",
            "maxSourceConnections": 1,
        },
        "position": {"x": 50, "y": 50},
    },
    # This node can have unlimited outgoing connections
    {
        "id": "multi-out",
        "type": "input",
        "data": {
            "label": "Multi Output (unlimited)",
        },
        "position": {"x": 300, "y": 50},
    },
    # This node can only receive 1 incoming connection
    {
        "id": "single-in",
        "type": "default",
        "data": {
            "label": "Single Input (max 1)",
            "maxTargetConnections": 1,
        },
        "position": {"x": 100, "y": 200},
    },
    # This node can receive 2 incoming connections
    {
        "id": "dual-in",
        "type": "default",
        "data": {
            "label": "Dual Input (max 2)",
            "maxTargetConnections": 2,
        },
        "position": {"x": 350, "y": 200},
    },
    # This node has a total connection limit (in + out)
    {
        "id": "limited-total",
        "type": "default",
        "data": {
            "label": "Total Limit (max 2 total)",
            "maxConnections": 2,
        },
        "position": {"x": 225, "y": 350},
    },
    # Output nodes with no limits
    {
        "id": "output-1",
        "type": "output",
        "data": {"label": "Output 1"},
        "position": {"x": 100, "y": 500},
    },
    {
        "id": "output-2",
        "type": "output",
        "data": {"label": "Output 2"},
        "position": {"x": 350, "y": 500},
    },
]

# Initial edges showing some connections already made
edges = [
    # Single-out already has its one allowed connection
    {"id": "e1", "source": "single-out", "target": "single-in"},
]

app.layout = html.Div([
    html.H1("Connection Limits Example"),
    html.P([
        "Try to create connections between nodes. Notice that:",
        html.Ul([
            html.Li("'Single Output' can only connect to one target (already connected)"),
            html.Li("'Single Input' can only receive one connection (already has one)"),
            html.Li("'Dual Input' can receive up to 2 connections"),
            html.Li("'Total Limit' can have max 2 connections total (in + out combined)"),
            html.Li("'Multi Output' has no limits"),
        ]),
    ]),

    # Status display
    html.Div(id="connection-status", style={
        "padding": "10px",
        "marginBottom": "10px",
        "background": "#e8f4e8",
        "borderRadius": "5px",
    }),

    dash_flows.DashFlows(
        id="flow",
        nodes=nodes,
        edges=edges,
        style={"height": "600px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        connectionRules={
            "allowSelfConnection": False,
            "allowDuplicateConnections": False,
        },
    ),
])


@callback(
    Output("connection-status", "children"),
    [Input("flow", "edges"),
     Input("flow", "lastConnection")],
)
def update_status(current_edges, last_connection):
    edge_count = len(current_edges) if current_edges else 0

    if last_connection:
        return f"Total connections: {edge_count} | Last connection: {last_connection.get('source', '')} -> {last_connection.get('target', '')}"

    return f"Total connections: {edge_count} | Drag from a handle to create a connection"


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `data.maxSourceConnections` — caps how many outgoing edges a node's source handle(s) will allow.
- `data.maxTargetConnections` — caps how many incoming edges a node's target handle(s) will allow.
- `data.maxConnections` — caps the combined total of incoming and outgoing edges on a node.
- `connectionRules={"allowSelfConnection": False, "allowDuplicateConnections": False}` — global rules applied on top of the per-node limits above.
- `Input("ex16-flow", "lastConnection")` — reports the most recently completed connection so the status bar can show `source -> target` as connections are made.

#### Context Menu

A fully custom right-click context menu built with `dmc.Menu` and `paneContextMenu`, with submenus for adding nodes, switching edge types, and changing the theme preset, color mode, and color scheme live.



```python
# File: examples/20_context_menu.py

"""
Example 20: Context Menu with Liquid Glass Styling
===================================================
This example demonstrates:
- Right-click context menu on the canvas
- Submenus for adding nodes (Input, Process, Output, Resizable)
- Submenus for edge types (Bezier, Straight, Step, SmoothStep)
- Theme preset selection (Glass, Solid, Minimal)
- Color mode toggle (Light, Dark, System)
- Color scheme selection (Default, Ocean, Forest, Sunset, Midnight, Rose)
- Dynamic node/edge creation
- Liquid glass morphism styling for menus
"""

import dash
from dash import html, callback, Input, Output, State, ctx, dcc
from dash.exceptions import PreventUpdate
import dash_flows
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import uuid

app = dash.Dash(
    __name__,
    assets_folder="assets",
    suppress_callback_exceptions=True
)

# Initial empty state
initial_nodes = []
initial_edges = []


def create_node_submenu():
    """Create the nodes submenu with node type options."""
    return dmc.SubMenu([
        dmc.SubMenuTarget(
            dmc.SubMenuItem(
                "Add Node",
                leftSection=DashIconify(icon="tabler:square-plus", width=18),
            )
        ),
        dmc.SubMenuDropdown(
            className="glass-morphism-menu",
            children=[
                dmc.MenuItem(
                    "Input Node",
                    id="add-input-node",
                    leftSection=html.Div("+", className="node-type-icon node-input-icon"),
                    rightSection=dmc.Text("Source", size="xs", c="dimmed"),
                ),
                dmc.MenuItem(
                    "Process Node",
                    id="add-process-node",
                    leftSection=html.Div("⚙", className="node-type-icon node-process-icon"),
                    rightSection=dmc.Text("Transform", size="xs", c="dimmed"),
                ),
                dmc.MenuItem(
                    "Output Node",
                    id="add-output-node",
                    leftSection=html.Div("→", className="node-type-icon node-output-icon"),
                    rightSection=dmc.Text("Sink", size="xs", c="dimmed"),
                ),
                dmc.MenuDivider(),
                dmc.MenuItem(
                    "Resizable Node",
                    id="add-resizable-node",
                    leftSection=html.Div("⤢", className="node-type-icon node-resizable-icon"),
                    rightSection=dmc.Text("Custom", size="xs", c="dimmed"),
                ),
            ]
        ),
    ])


def create_edge_submenu():
    """Create the edges submenu with edge type options."""
    return dmc.SubMenu([
        dmc.SubMenuTarget(
            dmc.SubMenuItem(
                "Edge Type",
                leftSection=DashIconify(icon="tabler:line", width=18),
            )
        ),
        dmc.SubMenuDropdown(
            className="glass-morphism-menu",
            children=[
                dmc.MenuItem(
                    "Bezier",
                    id="edge-bezier",
                    leftSection=DashIconify(icon="tabler:vector-bezier-2", width=18),
                ),
                dmc.MenuItem(
                    "Straight",
                    id="edge-straight",
                    leftSection=DashIconify(icon="tabler:line", width=18),
                ),
                dmc.MenuItem(
                    "Step",
                    id="edge-step",
                    leftSection=DashIconify(icon="tabler:stairs", width=18),
                ),
                dmc.MenuItem(
                    "Smooth Step",
                    id="edge-smoothstep",
                    leftSection=DashIconify(icon="tabler:corner-down-right", width=18),
                ),
                dmc.MenuDivider(),
                dmc.MenuItem(
                    "Animated",
                    id="edge-animated",
                    leftSection=DashIconify(icon="tabler:bolt", width=18, color="orange"),
                ),
            ]
        ),
    ])


def create_theme_preset_submenu():
    """Create the theme preset submenu."""
    return dmc.SubMenu([
        dmc.SubMenuTarget(
            dmc.SubMenuItem(
                "Theme Preset",
                leftSection=DashIconify(icon="tabler:palette", width=18),
            )
        ),
        dmc.SubMenuDropdown(
            className="glass-morphism-menu",
            children=[
                dmc.MenuItem(
                    "Glass",
                    id="preset-glass",
                    leftSection=html.Div(className="preset-indicator preset-glass"),
                    rightSection=dmc.Text("Blur effect", size="xs", c="dimmed"),
                ),
                dmc.MenuItem(
                    "Solid",
                    id="preset-solid",
                    leftSection=html.Div(className="preset-indicator preset-solid"),
                    rightSection=dmc.Text("Opaque", size="xs", c="dimmed"),
                ),
                dmc.MenuItem(
                    "Minimal",
                    id="preset-minimal",
                    leftSection=html.Div(className="preset-indicator preset-minimal"),
                    rightSection=dmc.Text("Clean", size="xs", c="dimmed"),
                ),
            ]
        ),
    ])


def create_color_mode_submenu():
    """Create the color mode submenu."""
    return dmc.SubMenu([
        dmc.SubMenuTarget(
            dmc.SubMenuItem(
                "Color Mode",
                leftSection=DashIconify(icon="tabler:sun-moon", width=18),
            )
        ),
        dmc.SubMenuDropdown(
            className="glass-morphism-menu",
            children=[
                dmc.MenuItem(
                    "Light",
                    id="mode-light",
                    leftSection=DashIconify(icon="tabler:sun", width=18, color="#f59e0b"),
                ),
                dmc.MenuItem(
                    "Dark",
                    id="mode-dark",
                    leftSection=DashIconify(icon="tabler:moon", width=18, color="#6366f1"),
                ),
                dmc.MenuItem(
                    "System",
                    id="mode-system",
                    leftSection=DashIconify(icon="tabler:device-desktop", width=18),
                ),
            ]
        ),
    ])


def create_color_scheme_submenu():
    """Create the color scheme submenu."""
    return dmc.SubMenu([
        dmc.SubMenuTarget(
            dmc.SubMenuItem(
                "Color Scheme",
                leftSection=DashIconify(icon="tabler:color-swatch", width=18),
            )
        ),
        dmc.SubMenuDropdown(
            className="glass-morphism-menu",
            children=[
                dmc.MenuItem(
                    "Default",
                    id="scheme-default",
                    leftSection=html.Div(className="color-scheme-swatch swatch-default"),
                ),
                dmc.MenuItem(
                    "Ocean",
                    id="scheme-ocean",
                    leftSection=html.Div(className="color-scheme-swatch swatch-ocean"),
                ),
                dmc.MenuItem(
                    "Forest",
                    id="scheme-forest",
                    leftSection=html.Div(className="color-scheme-swatch swatch-forest"),
                ),
                dmc.MenuItem(
                    "Sunset",
                    id="scheme-sunset",
                    leftSection=html.Div(className="color-scheme-swatch swatch-sunset"),
                ),
                dmc.MenuItem(
                    "Midnight",
                    id="scheme-midnight",
                    leftSection=html.Div(className="color-scheme-swatch swatch-midnight"),
                ),
                dmc.MenuItem(
                    "Rose",
                    id="scheme-rose",
                    leftSection=html.Div(className="color-scheme-swatch swatch-rose"),
                ),
            ]
        ),
    ])


def create_context_menu():
    """Create the full context menu structure."""
    return dmc.Menu(
        id="context-menu",
        opened=False,
        position="bottom-start",
        offset=0,
        withArrow=False,
        shadow="lg",
        width=220,
        zIndex=9999,
        closeOnClickOutside=True,
        closeOnEscape=True,
        children=[
            dmc.MenuTarget(
                html.Div(
                    id="context-menu-trigger",
                    className="context-menu-trigger",
                )
            ),
            dmc.MenuDropdown(
                className="glass-morphism-menu",
                children=[
                    dmc.MenuLabel("Nodes"),
                    create_node_submenu(),
                    dmc.MenuDivider(),
                    dmc.MenuLabel("Connections"),
                    create_edge_submenu(),
                    dmc.MenuDivider(),
                    dmc.MenuLabel("Appearance"),
                    create_theme_preset_submenu(),
                    create_color_mode_submenu(),
                    create_color_scheme_submenu(),
                    dmc.MenuDivider(),
                    dmc.MenuItem(
                        "Fit View",
                        id="action-fit-view",
                        leftSection=DashIconify(icon="tabler:arrows-maximize", width=18),
                    ),
                    dmc.MenuItem(
                        "Clear Canvas",
                        id="action-clear",
                        leftSection=DashIconify(icon="tabler:trash", width=18),
                        color="red",
                    ),
                ]
            ),
        ],
    )


def create_status_bar():
    """Create a status bar showing current settings."""
    return dmc.Group(
        className="status-bar",
        p="xs",
        gap="md",
        style={
            "position": "absolute",
            "bottom": 16,
            "left": 16,
            "zIndex": 100,
        },
        children=[
            dmc.Group(gap="xs", children=[
                DashIconify(icon="tabler:box", width=14),
                dmc.Text(id="node-count", size="sm", children="Nodes: 0"),
            ]),
            dmc.Divider(orientation="vertical", size="sm"),
            dmc.Group(gap="xs", children=[
                DashIconify(icon="tabler:line", width=14),
                dmc.Text(id="edge-count", size="sm", children="Edges: 0"),
            ]),
            dmc.Divider(orientation="vertical", size="sm"),
            dmc.Group(gap="xs", children=[
                DashIconify(icon="tabler:palette", width=14),
                dmc.Text(id="current-preset", size="sm", children="glass"),
            ]),
            dmc.Divider(orientation="vertical", size="sm"),
            dmc.Group(gap="xs", children=[
                DashIconify(icon="tabler:color-swatch", width=14),
                dmc.Text(id="current-scheme", size="sm", children="default"),
            ]),
        ]
    )


def create_help_tooltip():
    """Create a help tooltip."""
    return dmc.Paper(
        className="status-bar",
        p="sm",
        style={
            "position": "absolute",
            "top": 16,
            "left": 16,
            "zIndex": 100,
            "maxWidth": 280,
        },
        children=[
            dmc.Group(gap="xs", mb="xs", children=[
                DashIconify(icon="tabler:info-circle", width=18, color="blue"),
                dmc.Text("Right-Click Context Menu", fw=600, size="sm"),
            ]),
            dmc.Text(
                "Right-click anywhere on the canvas to open the context menu. "
                "Add nodes, change themes, and customize your flow.",
                size="xs",
                c="dimmed",
            ),
        ]
    )


app.layout = dmc.MantineProvider(
    id="mantine-provider",
    forceColorScheme="light",
    children=[
        # Stores for state management
        dcc.Store(id="menu-position", data={"x": 0, "y": 0}),
        dcc.Store(id="current-edge-type", data="smoothstep"),
        dcc.Store(id="edge-animated-state", data=False),
        dcc.Store(id="last-node-clicks", data={"input": 0, "process": 0, "output": 0, "resizable": 0}),

        html.Div(
            style={"position": "relative", "height": "100vh", "width": "100%"},
            children=[
                # Context menu (positioned via callback)
                html.Div(
                    id="context-menu-container",
                    className="context-menu-container",
                    style={"position": "fixed", "left": 0, "top": 0},
                    children=[create_context_menu()],
                ),

                # Help tooltip
                create_help_tooltip(),

                # Status bar
                create_status_bar(),

                # DashFlows component
                dash_flows.DashFlows(
                    id="flow",
                    nodes=initial_nodes,
                    edges=initial_edges,
                    colorMode="light",
                    themePreset="glass",
                    colorScheme="default",
                    theme={
                        "glassBlur": 12,
                        "borderRadius": 14,
                        "edgeStrokeWidth": 2,
                    },
                    showMiniMap=True,
                    showControls=True,
                    showBackground=True,
                    fitView=True,
                    fitViewOptions={"padding": 0.2},
                    defaultEdgeOptions={
                        "type": "smoothstep",
                        "animated": False,
                    },
                    style={"height": "100vh", "width": "100%"},
                ),
            ],
        ),
    ],
)


# Callback to handle right-click and show context menu
@callback(
    [Output("context-menu", "opened"),
     Output("context-menu-container", "style"),
     Output("menu-position", "data")],
    [Input("flow", "paneContextMenu")],
    prevent_initial_call=True,
)
def show_context_menu(context_menu):
    """Show context menu at right-click position."""
    if not context_menu:
        raise PreventUpdate

    x = context_menu.get("clientX", 0)
    y = context_menu.get("clientY", 0)

    return (
        True,
        {"position": "fixed", "left": x, "top": y, "zIndex": 9999},
        {"x": x, "y": y}
    )


# Callback to close menu on pane click
@callback(
    Output("context-menu", "opened", allow_duplicate=True),
    Input("flow", "paneClickPosition"),
    prevent_initial_call=True,
)
def close_menu_on_click(pane_click):
    """Close context menu when clicking on the pane."""
    return False


# Callback to add nodes
@callback(
    [Output("flow", "nodes"),
     Output("context-menu", "opened", allow_duplicate=True),
     Output("last-node-clicks", "data")],
    [Input("add-input-node", "n_clicks"),
     Input("add-process-node", "n_clicks"),
     Input("add-output-node", "n_clicks"),
     Input("add-resizable-node", "n_clicks")],
    [State("flow", "nodes"),
     State("menu-position", "data"),
     State("flow", "viewport"),
     State("last-node-clicks", "data")],
    prevent_initial_call=True,
)
def add_node(n1, n2, n3, n4, current_nodes, menu_pos, viewport, last_clicks):
    """Add a new node at the context menu position."""
    # Check which input actually triggered the callback
    if not ctx.triggered:
        raise PreventUpdate

    # Get the prop_id that triggered this callback
    triggered_prop = ctx.triggered[0]["prop_id"]
    triggered_value = ctx.triggered[0]["value"]

    # Only proceed if there was an actual click (value > 0)
    if not triggered_value:
        raise PreventUpdate

    # Extract the component id from prop_id (format: "component-id.property")
    triggered = triggered_prop.split(".")[0]

    # Map trigger to node type and last_clicks key
    node_types = {
        "add-input-node": ("input", "Input", "input"),
        "add-process-node": ("default", "Process", "process"),
        "add-output-node": ("output", "Output", "output"),
        "add-resizable-node": ("resizable", "Resizable", "resizable"),
    }

    if triggered not in node_types:
        raise PreventUpdate

    node_type, label, click_key = node_types[triggered]

    # Check if this is a new click by comparing with last stored value
    current_click = triggered_value or 0
    last_click = last_clicks.get(click_key, 0) if last_clicks else 0

    if current_click <= last_click:
        raise PreventUpdate

    # Update the last clicks store
    new_last_clicks = last_clicks.copy() if last_clicks else {"input": 0, "process": 0, "output": 0, "resizable": 0}
    new_last_clicks[click_key] = current_click

    # Calculate position in flow coordinates
    # Account for viewport transform
    zoom = viewport.get("zoom", 1) if viewport else 1
    vp_x = viewport.get("x", 0) if viewport else 0
    vp_y = viewport.get("y", 0) if viewport else 0

    x = (menu_pos["x"] - vp_x) / zoom
    y = (menu_pos["y"] - vp_y) / zoom

    # Generate unique ID
    node_id = f"{node_type}-{str(uuid.uuid4())[:8]}"

    # Create new node
    new_node = {
        "id": node_id,
        "type": node_type,
        "data": {
            "label": f"{label} {len(current_nodes) + 1 if current_nodes else 1}",
            "sublabel": f"Created at ({int(x)}, {int(y)})",
        },
        "position": {"x": x, "y": y},
    }

    # Add resizable-specific properties
    if node_type == "resizable":
        new_node["data"]["handles"] = [
            {"id": "top", "type": "target", "position": "top"},
            {"id": "bottom", "type": "source", "position": "bottom"},
        ]
        new_node["style"] = {"width": 180, "height": 100}

    updated_nodes = (current_nodes or []) + [new_node]

    return updated_nodes, False, new_last_clicks


# Callback to handle edge type selection
@callback(
    [Output("current-edge-type", "data"),
     Output("edge-animated-state", "data"),
     Output("flow", "defaultEdgeOptions"),
     Output("context-menu", "opened", allow_duplicate=True)],
    [Input("edge-bezier", "n_clicks"),
     Input("edge-straight", "n_clicks"),
     Input("edge-step", "n_clicks"),
     Input("edge-smoothstep", "n_clicks"),
     Input("edge-animated", "n_clicks")],
    [State("current-edge-type", "data"),
     State("edge-animated-state", "data")],
    prevent_initial_call=True,
)
def set_edge_type(n1, n2, n3, n4, n5, current_type, is_animated):
    """Set the default edge type for new connections."""
    if not ctx.triggered_id:
        raise PreventUpdate

    triggered = ctx.triggered_id

    edge_types = {
        "edge-bezier": "default",
        "edge-straight": "straight",
        "edge-step": "step",
        "edge-smoothstep": "smoothstep",
    }

    new_type = current_type
    new_animated = is_animated

    if triggered == "edge-animated":
        new_animated = not is_animated
    elif triggered in edge_types:
        new_type = edge_types[triggered]

    default_options = {
        "type": new_type,
        "animated": new_animated,
    }

    return new_type, new_animated, default_options, False


# Callback to handle theme preset selection
@callback(
    [Output("flow", "themePreset"),
     Output("current-preset", "children"),
     Output("context-menu", "opened", allow_duplicate=True)],
    [Input("preset-glass", "n_clicks"),
     Input("preset-solid", "n_clicks"),
     Input("preset-minimal", "n_clicks")],
    prevent_initial_call=True,
)
def set_theme_preset(n1, n2, n3):
    """Set the theme preset."""
    if not ctx.triggered_id:
        raise PreventUpdate

    presets = {
        "preset-glass": "glass",
        "preset-solid": "solid",
        "preset-minimal": "minimal",
    }

    preset = presets.get(ctx.triggered_id, "glass")
    return preset, preset, False


# Callback to handle color mode selection
@callback(
    [Output("flow", "colorMode"),
     Output("mantine-provider", "forceColorScheme"),
     Output("context-menu", "opened", allow_duplicate=True)],
    [Input("mode-light", "n_clicks"),
     Input("mode-dark", "n_clicks"),
     Input("mode-system", "n_clicks")],
    prevent_initial_call=True,
)
def set_color_mode(n1, n2, n3):
    """Set the color mode."""
    if not ctx.triggered_id:
        raise PreventUpdate

    modes = {
        "mode-light": "light",
        "mode-dark": "dark",
        "mode-system": "system",
    }

    mode = modes.get(ctx.triggered_id, "light")
    mantine_scheme = None if mode == "system" else mode

    return mode, mantine_scheme, False


# Callback to handle color scheme selection
@callback(
    [Output("flow", "colorScheme"),
     Output("current-scheme", "children"),
     Output("context-menu", "opened", allow_duplicate=True)],
    [Input("scheme-default", "n_clicks"),
     Input("scheme-ocean", "n_clicks"),
     Input("scheme-forest", "n_clicks"),
     Input("scheme-sunset", "n_clicks"),
     Input("scheme-midnight", "n_clicks"),
     Input("scheme-rose", "n_clicks")],
    prevent_initial_call=True,
)
def set_color_scheme(n1, n2, n3, n4, n5, n6):
    """Set the color scheme."""
    if not ctx.triggered_id:
        raise PreventUpdate

    schemes = {
        "scheme-default": "default",
        "scheme-ocean": "ocean",
        "scheme-forest": "forest",
        "scheme-sunset": "sunset",
        "scheme-midnight": "midnight",
        "scheme-rose": "rose",
    }

    scheme = schemes.get(ctx.triggered_id, "default")
    return scheme, scheme, False


# Callback to handle fit view action
@callback(
    [Output("flow", "viewportAction"),
     Output("context-menu", "opened", allow_duplicate=True)],
    Input("action-fit-view", "n_clicks"),
    prevent_initial_call=True,
)
def fit_view(n_clicks):
    """Trigger fit view action."""
    if not n_clicks:
        raise PreventUpdate

    # Use viewportAction to trigger fitView
    return {"action": "fitView", "options": {"padding": 0.2, "duration": 500}}, False


# Callback to clear all nodes and edges
@callback(
    [Output("flow", "nodes", allow_duplicate=True),
     Output("flow", "edges"),
     Output("context-menu", "opened", allow_duplicate=True)],
    Input("action-clear", "n_clicks"),
    prevent_initial_call=True,
)
def clear_canvas(n_clicks):
    """Clear all nodes and edges from the canvas."""
    if not n_clicks:
        raise PreventUpdate

    return [], [], False


# Callback to update status bar counts
@callback(
    [Output("node-count", "children"),
     Output("edge-count", "children")],
    [Input("flow", "nodes"),
     Input("flow", "edges")],
)
def update_counts(nodes, edges):
    """Update the node and edge counts in the status bar."""
    node_count = len(nodes) if nodes else 0
    edge_count = len(edges) if edges else 0

    return f"Nodes: {node_count}", f"Edges: {edge_count}"


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

    :defaultExpanded: false
    :withExpandedButton: true

The embedded demo above is trimmed to a fixed-height canvas that only themes itself (the full app in `examples/20_context_menu.py` runs at `100vh` and also syncs the page's overall Mantine color scheme from the same menu).

**How it works**

- `Input("ex20-flow", "paneContextMenu")` — fires on right-click over the canvas with `clientX` / `clientY`, which the callback uses to position the `dmc.Menu` and open it.
- `Input("ex20-flow", "paneClickPosition")` — fires on a plain left-click on the pane, used here to close the menu.
- `State("ex20-flow", "viewport")` — converts the screen-space menu position back into flow coordinates (accounting for pan/zoom) when placing a newly added node.
- `Output("ex20-flow", "themePreset")` / `"colorScheme"` / `"colorMode"` — the Appearance submenus write directly to these props to re-theme the canvas without a page reload.
- `Output("ex20-flow", "viewportAction")` with `{"action": "fitView", ...}` — the "Fit View" menu item triggers a programmatic viewport action.
- `assets/context_menu.css` (loaded automatically from the project's `assets/` folder) supplies the `glass-morphism-menu` and `status-bar` glassmorphism styling used by the menu, submenus, and overlays.


---

*Source: /interactions*

---

<!-- /layout — https://flows.2plot.dev/layout/llms.txt -->

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



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

---

<!-- /nodes — https://flows.2plot.dev/nodes/llms.txt -->

# Nodes

> Built-in node types, handle configuration, and Dash components or icons inside nodes.

---



### Overview

dash-flows ships several node types: `input` (source only), `output` (target only), `default` (both), `group` (a container for child nodes via `parentId`), `resizable`, `circle`, and `toolbar`. You can also render arbitrary Dash components and DashIconify icons inside a node's `data`.

### 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/nodes/demo.py

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

nodes = [{'id': 'in', 'type': 'input', 'data': {'label': 'input'}, 'position': {'x': 40, 'y': 20}},
 {'id': 'def', 'type': 'default', 'data': {'label': 'default'}, 'position': {'x': 40, 'y': 140}},
 {'id': 'out', 'type': 'output', 'data': {'label': 'output'}, 'position': {'x': 40, 'y': 260}},
 {'id': 'res',
  'type': 'resizable',
  'data': {'label': 'resizable'},
  'position': {'x': 260, 'y': 40},
  'style': {'width': 170, 'height': 90}},
 {'id': 'circ', 'type': 'circle', 'data': {'label': 'circle'}, 'position': {'x': 300, 'y': 200}},
 {'id': 'grp',
  'type': 'group',
  'data': {'label': 'group'},
  'position': {'x': 480, 'y': 20},
  'style': {'width': 220, 'height': 180}},
 {'id': 'c1',
  'type': 'default',
  'data': {'label': 'child 1'},
  'position': {'x': 20, 'y': 40},
  'parentId': 'grp',
  'extent': 'parent'},
 {'id': 'c2',
  'type': 'default',
  'data': {'label': 'child 2'},
  'position': {'x': 20, 'y': 110},
  'parentId': 'grp',
  'extent': 'parent'}]

edges = [{'id': 'n1', 'source': 'in', 'target': 'def'},
 {'id': 'n2', 'source': 'def', 'target': 'out'},
 {'id': 'n3', 'source': 'res', 'target': 'circ'}]

component = dash_flows.DashFlows(
    id="nodes-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=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>`.

#### All Node Types

Every built-in node type side by side: input, output, default, group, resizable, circle, and toolbar. Use this as a visual reference when picking a `type` for a new node.



```python
# File: examples/02_all_node_types.py

"""
Example 02: All Node Types
==========================
This example showcases all available node types in DashFlows:
- default: Standard node with both source and target handles
- input: Node with only source handle (start nodes)
- output: Node with only target handle (end nodes)
- group: Container node that can hold other nodes
- toolbar: Node with a configurable toolbar
- resizable: Node that can be resized by the user
- circle: Animated circular node
"""

import dash
from dash import html
import dash_flows

app = dash.Dash(__name__)

nodes = [
    # Input Node - Entry point (only source handle)
    {
        "id": "input-1",
        "type": "input",
        "data": {
            "label": "Input Node",
            "sublabel": "Entry point",
        },
        "position": {"x": 50, "y": 50},
    },

    # Default Node - Standard processing node
    {
        "id": "default-1",
        "type": "default",
        "data": {
            "label": "Default Node",
            "sublabel": "Both handles",
        },
        "position": {"x": 50, "y": 200},
    },

    # Output Node - Exit point (only target handle)
    {
        "id": "output-1",
        "type": "output",
        "data": {
            "label": "Output Node",
            "sublabel": "Exit point",
        },
        "position": {"x": 50, "y": 350},
    },

    # Toolbar Node - With action buttons
    {
        "id": "toolbar-1",
        "type": "toolbar",
        "data": {
            "label": "Toolbar Node",
            "sublabel": "Click to see toolbar",
            "toolbarPosition": "top",
        },
        "position": {"x": 300, "y": 50},
    },

    # Resizable Node - Can be resized
    {
        "id": "resizable-1",
        "type": "resizable",
        "data": {
            "label": html.Div([
                html.Strong("Resizable Node"),
                html.P("Drag corners to resize", style={"fontSize": "11px"}),
            ]),
            "handles": [
                {"type": "target", "position": "top", "id": "r-top"},
                {"type": "source", "position": "bottom", "id": "r-bottom"},
            ],
        },
        "position": {"x": 300, "y": 200},
        "style": {"width": 200, "height": 100},
    },

    # Circle Node - Animated circular node
    {
        "id": "circle-1",
        "type": "circle",
        "data": {"label": "A"},
        "position": {"x": 340, "y": 360},
    },

    # Group Node - Container for other nodes
    {
        "id": "group-1",
        "type": "group",
        "data": {
            "label": "Group Container",
        },
        "position": {"x": 550, "y": 50},
        "style": {"width": 250, "height": 300},
    },

    # Child nodes inside the group
    {
        "id": "child-1",
        "type": "default",
        "data": {"label": "Child A"},
        "position": {"x": 50, "y": 50},  # Position below the label badge
        "parentId": "group-1",
        "extent": "parent",  # Keep within parent bounds
    },
    {
        "id": "child-2",
        "type": "default",
        "data": {"label": "Child B"},
        "position": {"x": 50, "y": 160},
        "parentId": "group-1",
        "extent": "parent",
    },
]

edges = [
    {"id": "e1", "source": "input-1", "target": "default-1"},
    {"id": "e2", "source": "default-1", "target": "output-1"},
    {"id": "e3", "source": "toolbar-1", "target": "resizable-1", "sourceHandle": None, "targetHandle": "r-top"},
    {"id": "e4", "source": "resizable-1", "target": "circle-1", "sourceHandle": "r-bottom"},
    {"id": "e5", "source": "child-1", "target": "child-2"},
]

app.layout = html.Div([
    html.H1("All Node Types Example"),
    html.P("Demonstrates every available node type in DashFlows."),
    html.Ul([
        html.Li("Input Node: Green accent, only outgoing connections"),
        html.Li("Default Node: Standard node with both connection types"),
        html.Li("Output Node: Purple accent, only incoming connections"),
        html.Li("Toolbar Node: Click to reveal action toolbar"),
        html.Li("Resizable Node: Drag corners to resize"),
        html.Li("Circle Node: Animated circular indicator"),
        html.Li("Group Node: Container that holds child nodes"),
    ]),
    dash_flows.DashFlows(
        id="node-types-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "600px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
    ),
])

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `type: "input"` / `"output"` — single-handle nodes with a green (source-only) or purple (target-only) accent, good for entry/exit points.
- `type: "toolbar"` — reveals an action toolbar above the node on selection via `data.toolbarPosition`.
- `type: "resizable"` with `data.handles` — a resizable node with a custom list of handles (each with `type`, `position`, and `id`), used here to expose a top target and bottom source handle.
- `type: "group"` plus `parentId` / `extent: "parent"` on child nodes — nests nodes inside a container that moves and resizes as a unit.
- `type: "circle"` — a compact animated circular node, handy for status dots or simple junctions.

#### Handle Configurations

Control where connection handles sit, how many a node has, and how they're styled — beyond the single default source/target pair.



```python
# File: examples/06_handle_configurations.py

"""
Example 06: Handle Configurations
=================================
This example demonstrates various handle configurations:
- Multiple handles per node
- Handle positions (top, bottom, left, right)
- Custom handle IDs for specific connections
- Handle styling
"""

import dash
from dash import html
import dash_flows

app = dash.Dash(__name__)

nodes = [
    # Node with multiple handles on different sides
    {
        "id": "multi-handle",
        "type": "resizable",
        "data": {
            "label": html.Div([
                html.Strong("Multi-Handle Node"),
                html.P("4 handles on each side", style={"fontSize": "11px", "margin": 0}),
            ]),
            "handles": [
                {"type": "target", "position": "top", "id": "top-in", "style": {"background": "#10b981"}},
                {"type": "target", "position": "left", "id": "left-in", "style": {"background": "#3b82f6"}},
                {"type": "source", "position": "bottom", "id": "bottom-out", "style": {"background": "#8b5cf6"}},
                {"type": "source", "position": "right", "id": "right-out", "style": {"background": "#f59e0b"}},
            ],
        },
        "position": {"x": 250, "y": 150},
        "style": {"width": 180, "height": 80},
    },

    # Source nodes feeding into the multi-handle node
    {
        "id": "top-source",
        "type": "input",
        "data": {"label": "Top Source"},
        "position": {"x": 270, "y": 20},
    },
    {
        "id": "left-source",
        "type": "input",
        "data": {"label": "Left Source"},
        "position": {"x": 50, "y": 160},
    },

    # Target nodes receiving from the multi-handle node
    {
        "id": "bottom-target",
        "type": "output",
        "data": {"label": "Bottom Target"},
        "position": {"x": 270, "y": 310},
    },
    {
        "id": "right-target",
        "type": "output",
        "data": {"label": "Right Target"},
        "position": {"x": 480, "y": 160},
    },

    # Node with offset handles
    {
        "id": "offset-handles",
        "type": "resizable",
        "data": {
            "label": html.Div([
                html.Strong("Offset Handles"),
                html.P("Handles at specific positions", style={"fontSize": "11px", "margin": 0}),
            ]),
            "handles": [
                {"type": "target", "position": "top", "id": "t1", "style": {"left": "25%", "background": "#ef4444"}},
                {"type": "target", "position": "top", "id": "t2", "style": {"left": "75%", "background": "#ef4444"}},
                {"type": "source", "position": "bottom", "id": "s1", "style": {"left": "25%", "background": "#22c55e"}},
                {"type": "source", "position": "bottom", "id": "s2", "style": {"left": "75%", "background": "#22c55e"}},
            ],
        },
        "position": {"x": 250, "y": 420},
        "style": {"width": 180, "height": 80},
    },

    # Nodes to connect to offset handles
    {
        "id": "offset-source-1",
        "type": "input",
        "data": {"label": "A"},
        "position": {"x": 220, "y": 350},
    },
    {
        "id": "offset-source-2",
        "type": "input",
        "data": {"label": "B"},
        "position": {"x": 380, "y": 350},
    },
    {
        "id": "offset-target-1",
        "type": "output",
        "data": {"label": "X"},
        "position": {"x": 220, "y": 560},
    },
    {
        "id": "offset-target-2",
        "type": "output",
        "data": {"label": "Y"},
        "position": {"x": 380, "y": 560},
    },
]

edges = [
    # Connections to multi-handle node
    {"id": "e1", "source": "top-source", "target": "multi-handle", "targetHandle": "top-in"},
    {"id": "e2", "source": "left-source", "target": "multi-handle", "targetHandle": "left-in"},
    {"id": "e3", "source": "multi-handle", "target": "bottom-target", "sourceHandle": "bottom-out"},
    {"id": "e4", "source": "multi-handle", "target": "right-target", "sourceHandle": "right-out"},

    # Connections to offset handles node
    {"id": "e5", "source": "offset-source-1", "target": "offset-handles", "targetHandle": "t1"},
    {"id": "e6", "source": "offset-source-2", "target": "offset-handles", "targetHandle": "t2"},
    {"id": "e7", "source": "offset-handles", "target": "offset-target-1", "sourceHandle": "s1"},
    {"id": "e8", "source": "offset-handles", "target": "offset-target-2", "sourceHandle": "s2"},
]

app.layout = html.Div([
    html.H1("Handle Configurations Example"),
    html.P("Demonstrates multiple handles, custom positions, and handle styling."),
    html.Ul([
        html.Li("Multi-Handle Node: 4 handles, one on each side with different colors"),
        html.Li("Offset Handles: Multiple handles on the same side at specific positions"),
        html.Li("Each handle has a unique ID for precise edge connections"),
    ]),
    dash_flows.DashFlows(
        id="handle-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "700px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
    ),
])

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `data.handles` — a list of `{type, position, id, style}` dicts; each entry adds one handle to the node, so a node can mix any number of sources and targets on any side.
- `position` (`"top"` / `"bottom"` / `"left"` / `"right"`) picks which edge of the node the handle sits on.
- `style.left` (e.g. `"25%"`, `"75%"`) offsets multiple handles that share the same side so they don't overlap.
- `sourceHandle` / `targetHandle` on an edge reference a specific handle `id`, letting you route several distinct connections through one node.
- `style.background` colors each handle individually, which is useful for visually pairing a handle with the edges that use it.

#### Dash Components in Nodes

Render arbitrary Dash and Dash Mantine components — including live charts — as node content, and combine that with a "detail panel" pattern for content too heavy to keep inline.



```python
# File: examples/14_dash_components_in_nodes.py

"""
Example 14: Dash Components in Resizable Nodes
===============================================
This example demonstrates embedding HTML content inside resizable flow nodes.

What works inside ResizableNode:
- html.* components (Div, Span, Button, Img, etc.)
- CSS-styled visualizations (progress bars, stat cards)
- Simple text and formatted content

What requires the "detail panel" pattern:
- dcc.Graph (Plotly charts)
- Complex interactive components
- Components with callbacks

This example shows both patterns working together.
"""

import dash
from dash import html, dcc, Input, Output, callback
import dash_flows
import dash_mantine_components as dmc
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd

app = dash.Dash(__name__, suppress_callback_exceptions=True)

# Sample data for the detail panel charts
df = pd.DataFrame({
    "Month": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
    "Revenue": [12400, 15800, 14200, 18900, 21000, 19500],
    "Users": [1200, 1450, 1380, 1720, 1950, 1840],
})


# ============================================
# Node Definitions
# ============================================

nodes = [
    # Input Node - Data Source
    {
        "id": "data-source",
        "type": "input",
        "data": {"label": "Data Source", "sublabel": "API Feed"},
        "position": {"x": 50, "y": 150},
    },

    # Resizable Node - Revenue Metrics Card
    {
        "id": "revenue-card",
        "type": "resizable",
        "data": {
            "label": html.Div([
                # Header
                html.Div("Revenue", style={
                    "fontSize": "12px",
                    "color": "#6b7280",
                    "marginBottom": "4px",
                }),
                # Main value
                html.Div("$19,500", style={
                    "fontSize": "28px",
                    "fontWeight": "bold",
                    "color": "#1a1b1e",
                }),
                # Trend indicator
                html.Div([
                    html.Span("+8.2%", style={
                        "color": "#10b981",
                        "fontWeight": "600",
                        "fontSize": "13px",
                    }),
                    html.Span(" vs last month", style={
                        "color": "#9ca3af",
                        "fontSize": "11px",
                    }),
                ], style={"marginTop": "4px"}),
                # Mini bar chart using CSS
                html.Div([
                    html.Div(style={"flex": "1", "height": "20px", "background": "#dbeafe", "borderRadius": "2px", "margin": "1px"}),
                    html.Div(style={"flex": "1", "height": "28px", "background": "#93c5fd", "borderRadius": "2px", "margin": "1px"}),
                    html.Div(style={"flex": "1", "height": "24px", "background": "#60a5fa", "borderRadius": "2px", "margin": "1px"}),
                    html.Div(style={"flex": "1", "height": "35px", "background": "#3b82f6", "borderRadius": "2px", "margin": "1px"}),
                    html.Div(style={"flex": "1", "height": "42px", "background": "#2563eb", "borderRadius": "2px", "margin": "1px"}),
                    html.Div(style={"flex": "1", "height": "38px", "background": "#1d4ed8", "borderRadius": "2px", "margin": "1px"}),
                ], style={
                    "display": "flex",
                    "alignItems": "flex-end",
                    "marginTop": "12px",
                    "height": "45px",
                }),
            ], style={
                "padding": "12px",
                "height": "100%",
                "boxSizing": "border-box",
            }),
            "handles": [
                {"type": "target", "position": "left", "id": "revenue-in"},
                {"type": "source", "position": "right", "id": "revenue-out"},
            ],
            "minWidth": 160,
            "minHeight": 140,
            "padding": 0,
        },
        "position": {"x": 250, "y": 50},
        "style": {"width": 180, "height": 160},
    },

    # Resizable Node - User Metrics Card
    {
        "id": "users-card",
        "type": "resizable",
        "data": {
            "label": html.Div([
                # Header
                html.Div("Active Users", style={
                    "fontSize": "12px",
                    "color": "#6b7280",
                    "marginBottom": "4px",
                }),
                # Main value
                html.Div("1,840", style={
                    "fontSize": "28px",
                    "fontWeight": "bold",
                    "color": "#1a1b1e",
                }),
                # Trend indicator
                html.Div([
                    html.Span("-5.6%", style={
                        "color": "#ef4444",
                        "fontWeight": "600",
                        "fontSize": "13px",
                    }),
                    html.Span(" vs last month", style={
                        "color": "#9ca3af",
                        "fontSize": "11px",
                    }),
                ], style={"marginTop": "4px"}),
                # Progress ring simulation
                html.Div([
                    html.Div(style={
                        "width": "50px",
                        "height": "50px",
                        "borderRadius": "50%",
                        "border": "4px solid #e5e7eb",
                        "borderTopColor": "#8b5cf6",
                        "borderRightColor": "#8b5cf6",
                        "transform": "rotate(45deg)",
                    }),
                    html.Div("73%", style={
                        "position": "absolute",
                        "fontSize": "11px",
                        "fontWeight": "bold",
                        "color": "#6b7280",
                    }),
                ], style={
                    "display": "flex",
                    "alignItems": "center",
                    "justifyContent": "center",
                    "marginTop": "8px",
                    "position": "relative",
                }),
            ], style={
                "padding": "12px",
                "height": "100%",
                "boxSizing": "border-box",
            }),
            "handles": [
                {"type": "target", "position": "left", "id": "users-in"},
                {"type": "source", "position": "right", "id": "users-out"},
            ],
            "minWidth": 160,
            "minHeight": 140,
            "padding": 0,
        },
        "position": {"x": 250, "y": 240},
        "style": {"width": 180, "height": 170},
    },

    # Resizable Node - Processing Status
    {
        "id": "status-card",
        "type": "resizable",
        "data": {
            "label": html.Div([
                html.Div("Pipeline Status", style={
                    "fontSize": "13px",
                    "fontWeight": "600",
                    "color": "#1a1b1e",
                    "marginBottom": "12px",
                }),
                # Stage 1 - Complete
                html.Div([
                    html.Div([
                        html.Span("Extract", style={"fontSize": "11px", "color": "#374151"}),
                        html.Span("Done", style={"fontSize": "10px", "color": "#10b981", "marginLeft": "auto"}),
                    ], style={"display": "flex", "marginBottom": "4px"}),
                    html.Div([
                        html.Div(style={
                            "width": "100%",
                            "height": "6px",
                            "background": "#10b981",
                            "borderRadius": "3px",
                        })
                    ], style={"background": "#e5e7eb", "borderRadius": "3px"}),
                ], style={"marginBottom": "10px"}),
                # Stage 2 - In Progress
                html.Div([
                    html.Div([
                        html.Span("Transform", style={"fontSize": "11px", "color": "#374151"}),
                        html.Span("75%", style={"fontSize": "10px", "color": "#3b82f6", "marginLeft": "auto"}),
                    ], style={"display": "flex", "marginBottom": "4px"}),
                    html.Div([
                        html.Div(style={
                            "width": "75%",
                            "height": "6px",
                            "background": "#3b82f6",
                            "borderRadius": "3px",
                        })
                    ], style={"background": "#e5e7eb", "borderRadius": "3px"}),
                ], style={"marginBottom": "10px"}),
                # Stage 3 - Pending
                html.Div([
                    html.Div([
                        html.Span("Load", style={"fontSize": "11px", "color": "#374151"}),
                        html.Span("Pending", style={"fontSize": "10px", "color": "#9ca3af", "marginLeft": "auto"}),
                    ], style={"display": "flex", "marginBottom": "4px"}),
                    html.Div([
                        html.Div(style={
                            "width": "0%",
                            "height": "6px",
                            "background": "#9ca3af",
                            "borderRadius": "3px",
                        })
                    ], style={"background": "#e5e7eb", "borderRadius": "3px"}),
                ]),
            ], style={
                "padding": "12px",
                "height": "100%",
                "boxSizing": "border-box",
            }),
            "handles": [
                {"type": "target", "position": "top", "id": "status-in-1"},
                {"type": "target", "position": "left", "id": "status-in-2"},
                {"type": "source", "position": "right", "id": "status-out"},
            ],
            "minWidth": 150,
            "minHeight": 150,
            "padding": 0,
        },
        "position": {"x": 500, "y": 130},
        "style": {"width": 170, "height": 175},
    },

    # Output Node - Dashboard
    {
        "id": "dashboard",
        "type": "output",
        "data": {"label": "Dashboard", "sublabel": "Final Output"},
        "position": {"x": 730, "y": 175},
    },
]

# Edge connections
edges = [
    {"id": "e1", "source": "data-source", "target": "revenue-card", "targetHandle": "revenue-in"},
    {"id": "e2", "source": "data-source", "target": "users-card", "targetHandle": "users-in"},
    {"id": "e3", "source": "revenue-card", "target": "status-card", "sourceHandle": "revenue-out", "targetHandle": "status-in-1"},
    {"id": "e4", "source": "users-card", "target": "status-card", "sourceHandle": "users-out", "targetHandle": "status-in-2"},
    {"id": "e5", "source": "status-card", "target": "dashboard", "sourceHandle": "status-out"},
]


# ============================================
# Chart Functions for Detail Panel
# ============================================

def create_revenue_chart():
    """Full Plotly chart shown in detail panel."""
    fig = px.bar(
        df, x="Month", y="Revenue",
        color_discrete_sequence=["#3b82f6"]
    )
    fig.update_layout(
        margin=dict(l=40, r=20, t=40, b=40),
        paper_bgcolor="rgba(0,0,0,0)",
        plot_bgcolor="rgba(0,0,0,0)",
        title=dict(text="Monthly Revenue", font=dict(size=14)),
        height=280,
    )
    return fig


def create_users_chart():
    """Full Plotly chart shown in detail panel."""
    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=df["Month"],
        y=df["Users"],
        mode="lines+markers",
        line=dict(color="#8b5cf6", width=3),
        marker=dict(size=8),
        fill="tozeroy",
        fillcolor="rgba(139, 92, 246, 0.1)",
    ))
    fig.update_layout(
        margin=dict(l=40, r=20, t=40, b=40),
        paper_bgcolor="rgba(0,0,0,0)",
        plot_bgcolor="rgba(0,0,0,0)",
        title=dict(text="User Growth", font=dict(size=14)),
        height=280,
    )
    return fig


# ============================================
# App Layout
# ============================================

app.layout = dmc.MantineProvider([
    html.H2("Dash Components in Resizable Nodes", style={"marginBottom": "8px"}),
    html.P("Click on metric cards to see full charts in the detail panel.",
           style={"color": "#6b7280", "marginBottom": "16px"}),

    dmc.Grid([
        # Flow canvas
        dmc.GridCol([
            dash_flows.DashFlows(
                id="metrics-flow",
                nodes=nodes,
                edges=edges,
                style={"height": "480px", "border": "1px solid #e5e7eb", "borderRadius": "8px"},
                fitView=True,
                showControls=True,
                showMiniMap=False,
                nodesDraggable=True,
                elementsSelectable=True,
            ),
        ], span=8),

        # Detail panel
        dmc.GridCol([
            dmc.Paper([
                dmc.Text("Node Details", fw=600, size="sm", mb="sm"),
                html.Div(id="node-info", children=[
                    dmc.Text("Click a node to see details", c="dimmed", size="sm"),
                ]),
            ], p="md", withBorder=True, mb="md"),

            dmc.Paper([
                dmc.Text("Chart Preview", fw=600, size="sm", mb="sm"),
                html.Div(id="chart-panel", children=[
                    dmc.Text("Select a metric card to see the full chart", c="dimmed", size="sm"),
                ]),
            ], p="md", withBorder=True, style={"minHeight": "320px"}),
        ], span=4),
    ]),
])


# ============================================
# Callbacks
# ============================================

@callback(
    Output("node-info", "children"),
    Output("chart-panel", "children"),
    Input("metrics-flow", "clickedNode"),
    prevent_initial_call=True,
)
def show_node_details(clicked_node):
    """Show node info and corresponding chart when a node is clicked."""
    if not clicked_node:
        return (
            dmc.Text("No node selected", c="dimmed", size="sm"),
            dmc.Text("Click a metric card", c="dimmed", size="sm"),
        )

    node_id = clicked_node.get("id", "Unknown")
    node_type = clicked_node.get("type", "unknown")
    position = clicked_node.get("position", {})

    # Node info display
    info = dmc.Stack([
        dmc.Group([
            dmc.Text("ID:", size="sm", fw=500),
            dmc.Badge(node_id, variant="light", color="blue"),
        ], gap="xs"),
        dmc.Group([
            dmc.Text("Type:", size="sm", fw=500),
            dmc.Badge(node_type, variant="light", color="gray"),
        ], gap="xs"),
        dmc.Group([
            dmc.Text("Position:", size="sm", fw=500),
            dmc.Code(f"({position.get('x', 0):.0f}, {position.get('y', 0):.0f})"),
        ], gap="xs"),
    ], gap="xs")

    # Chart based on selected node
    if node_id == "revenue-card":
        chart = dcc.Graph(
            figure=create_revenue_chart(),
            config={"displayModeBar": False},
        )
    elif node_id == "users-card":
        chart = dcc.Graph(
            figure=create_users_chart(),
            config={"displayModeBar": False},
        )
    elif node_id == "status-card":
        chart = dmc.Stack([
            dmc.Text("ETL Pipeline Status", fw=600, size="sm"),
            dmc.Progress(value=100, color="green", size="lg", mb="xs"),
            dmc.Text("Extract: Complete", size="xs", c="dimmed"),
            dmc.Progress(value=75, color="blue", size="lg", mb="xs"),
            dmc.Text("Transform: 75%", size="xs", c="dimmed"),
            dmc.Progress(value=0, color="gray", size="lg", mb="xs"),
            dmc.Text("Load: Pending", size="xs", c="dimmed"),
        ], gap="sm")
    else:
        chart = dmc.Text(f"No chart for: {node_id}", c="dimmed", size="sm")

    return info, chart


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `data.label` accepts any Dash component tree (here, `html.Div` stat cards with CSS-drawn mini bar charts and a progress ring) instead of a plain string.
- `type: "resizable"` nodes size themselves to their content via `data.minWidth` / `data.minHeight`, so cards keep their layout as they're resized.
- `clickedNode` (an Input on the flow's id) fires with the clicked node's full dict, letting a callback branch on `node["id"]` to render node-specific detail — full `dcc.Graph` charts live in a side panel rather than inside the node itself.
- Multiple named handles (`revenue-in`, `status-out`, etc.) route several cards into a single "Pipeline Status" node without ambiguity.
- This mirrors the library's guidance: simple HTML/CSS content can live directly in a node, while `dcc.Graph` and other heavy/interactive components are better shown in a panel driven by node click events.

#### Custom Icons

Use DashIconify icons inside node `data`, and combine them with the `layout` ("stacked" vs "horizontal") and `showIcon` options to control node composition — then update everything live from Dash Mantine form controls.



```python
# File: examples/22_custom_icons.py

"""
Example 22: Custom Icons with DashIconify & Layout Options
===========================================================
This example demonstrates how to use DashIconify icons in nodes
and the new layout system for flexible node styling.

Features:
- Custom icons via DashIconify
- Dynamic icon updates via DMC form controls
- Title and body text customization
- Layout options: 'stacked' (vertical) or 'horizontal' (two-column)
- Content-aware sizing: icon-only, text-only, or full-content nodes

Requirements:
    pip install dash-iconify dash-mantine-components

Icon Search: https://icon-sets.iconify.design/
"""

import dash
from dash import html, callback, Input, Output, State, ALL, ctx
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import dash_flows

app = dash.Dash(
    __name__,
    external_stylesheets=[
        "https://unpkg.com/@mantine/core@7.11.0/styles.css",
    ],
)

# Initial node configurations showcasing different layouts and content modes
initial_nodes = [
    # Full content node with icon, title, and body (stacked layout)
    {
        "id": "input-1",
        "type": "input",
        "data": {
            "icon": DashIconify(icon="mdi:database", width=20, color="white"),
            "label": "Data Source",
            "body": "PostgreSQL Database",
            "layout": "stacked",
        },
        "position": {"x": 100, "y": 50},
    },
    # Icon-only node (compact sizing)
    {
        "id": "icon-only",
        "type": "default",
        "data": {
            "icon": DashIconify(icon="mdi:lightning-bolt", width=24, color="white"),
            "showIcon": True,
            "iconColor": "#f59e0b",
        },
        "position": {"x": 300, "y": 50},
    },
    # Full content node with horizontal layout
    {
        "id": "process-1",
        "type": "default",
        "data": {
            "icon": DashIconify(icon="mdi:cog", width=20, color="white"),
            "label": "Transform",
            "body": "Clean and normalize",
            "layout": "horizontal",
            "showIcon": True,
        },
        "position": {"x": 100, "y": 200},
    },
    # Text-only node (centered, no icon space reserved)
    {
        "id": "text-only",
        "type": "default",
        "data": {
            "label": "Validate",
            "sublabel": "Quality check",
            "showIcon": False,
        },
        "position": {"x": 300, "y": 200},
    },
    # Output with horizontal layout
    {
        "id": "output-1",
        "type": "output",
        "data": {
            "icon": DashIconify(icon="mdi:chart-bar", width=20, color="white"),
            "label": "Dashboard",
            "body": "Visualization",
            "layout": "horizontal",
        },
        "position": {"x": 100, "y": 350},
    },
    # Icon-only output
    {
        "id": "output-icon",
        "type": "output",
        "data": {
            "icon": DashIconify(icon="mdi:file-export", width=24, color="white"),
            "showIcon": True,
        },
        "position": {"x": 300, "y": 350},
    },
]

initial_edges = [
    {"id": "e1-p1", "source": "input-1", "target": "process-1", "animated": True},
    {"id": "e1-icon", "source": "icon-only", "target": "text-only", "animated": True},
    {"id": "ep1-o1", "source": "process-1", "target": "output-1", "animated": True},
    {"id": "et-oicon", "source": "text-only", "target": "output-icon", "animated": True},
]

# Popular icon suggestions for each node type
icon_suggestions = {
    "input": [
        "mdi:database",
        "mdi:file-document",
        "mdi:api",
        "mdi:cloud-download",
        "mdi:folder",
        "mdi:web",
        "mdi:server",
        "mdi:import",
    ],
    "process": [
        "mdi:cog",
        "mdi:function",
        "mdi:filter",
        "mdi:merge",
        "mdi:swap-horizontal",
        "mdi:code-braces",
        "mdi:math-integral",
        "mdi:lightning-bolt",
    ],
    "output": [
        "mdi:chart-bar",
        "mdi:file-export",
        "mdi:monitor-dashboard",
        "mdi:email-send",
        "mdi:cloud-upload",
        "mdi:database-export",
        "mdi:printer",
        "mdi:share-variant",
    ],
}


def create_node_editor(
    node_id: str,
    node_type: str,
    default_icon: str,
    default_title: str,
    default_body: str,
    default_layout: str = "stacked",
    show_icon: bool = True,
):
    """Create a form section for editing a node's icon, title, body, and layout."""
    suggestions = icon_suggestions.get(node_type, icon_suggestions["process"])

    # Color based on node type
    accent_color = {
        "input": "#10b981",
        "process": "#3b82f6",
        "output": "#8b5cf6",
    }.get(node_type, "#3b82f6")

    return dmc.Paper(
        [
            dmc.Group(
                [
                    DashIconify(
                        icon=default_icon,
                        width=24,
                        color=accent_color,
                    ),
                    dmc.Text(
                        f"{node_type.title()} Node",
                        size="lg",
                        fw=600,
                    ),
                ],
                gap="sm",
                mb="md",
            ),
            # Layout toggle
            dmc.Group(
                [
                    dmc.Text("Layout:", size="sm", fw=500),
                    dmc.SegmentedControl(
                        id={"type": "layout-toggle", "node": node_id},
                        data=[
                            {"value": "stacked", "label": "Stacked"},
                            {"value": "horizontal", "label": "Horizontal"},
                        ],
                        value=default_layout,
                        size="xs",
                    ),
                ],
                gap="sm",
                mb="sm",
            ),
            # Show icon toggle
            dmc.Switch(
                id={"type": "show-icon-toggle", "node": node_id},
                label="Show Icon",
                checked=show_icon,
                size="sm",
                mb="sm",
            ),
            dmc.TextInput(
                id={"type": "icon-input", "node": node_id},
                label="Icon",
                description="Enter an icon name from iconify.design",
                placeholder="mdi:database",
                value=default_icon,
                leftSection=DashIconify(icon="mdi:emoticon", width=16),
                mb="sm",
                disabled=not show_icon,
            ),
            dmc.Group(
                [
                    dmc.Text("Suggestions:", size="xs", c="dimmed"),
                    *[
                        dmc.Badge(
                            icon,
                            size="sm",
                            variant="light",
                            color="gray",
                            style={"cursor": "pointer"},
                            id={"type": "icon-suggestion", "node": node_id, "icon": icon},
                        )
                        for icon in suggestions[:4]
                    ],
                ],
                gap="xs",
                mb="md",
            ),
            dmc.TextInput(
                id={"type": "title-input", "node": node_id},
                label="Title",
                placeholder="Node title",
                value=default_title,
                leftSection=DashIconify(icon="mdi:format-title", width=16),
                mb="sm",
            ),
            dmc.Textarea(
                id={"type": "body-input", "node": node_id},
                label="Body Text",
                placeholder="Description text",
                value=default_body,
                autosize=True,
                minRows=2,
                maxRows=4,
            ),
        ],
        p="md",
        radius="md",
        withBorder=True,
        style={"borderLeft": f"4px solid {accent_color}"},
    )


app.layout = dmc.MantineProvider(
    [
        dmc.Container(
            [
                dmc.Title("Custom Icons & Layout Options", order=1, mb="xs"),
                dmc.Text(
                    "Customize node icons, layouts, and content dynamically.",
                    c="dimmed",
                    mb="md",
                ),
                dmc.Group(
                    [
                        dmc.Anchor(
                            dmc.Group(
                                [
                                    DashIconify(icon="mdi:magnify", width=16),
                                    "Search icons at iconify.design",
                                ],
                                gap="xs",
                            ),
                            href="https://icon-sets.iconify.design/",
                            target="_blank",
                        ),
                        dmc.Badge("Stacked = Vertical layout", color="blue", variant="light"),
                        dmc.Badge("Horizontal = Two-column layout", color="green", variant="light"),
                    ],
                    gap="md",
                    mb="lg",
                ),
                # Layout preview section
                dmc.Paper(
                    [
                        dmc.Text("Content Mode Examples", size="sm", fw=500, mb="xs"),
                        dmc.Group(
                            [
                                dmc.Badge("Icon + Text = Full Content", color="violet", variant="outline"),
                                dmc.Badge("Icon Only = Compact", color="orange", variant="outline"),
                                dmc.Badge("Text Only = Centered", color="cyan", variant="outline"),
                            ],
                            gap="xs",
                        ),
                    ],
                    p="sm",
                    radius="md",
                    bg="gray.0",
                    mb="md",
                ),
                dmc.Grid(
                    [
                        # Left panel - Node editors
                        dmc.GridCol(
                            dmc.Stack(
                                [
                                    create_node_editor(
                                        "input-1",
                                        "input",
                                        "mdi:database",
                                        "Data Source",
                                        "PostgreSQL Database",
                                        default_layout="stacked",
                                        show_icon=True,
                                    ),
                                    create_node_editor(
                                        "process-1",
                                        "process",
                                        "mdi:cog",
                                        "Transform",
                                        "Clean and normalize",
                                        default_layout="horizontal",
                                        show_icon=True,
                                    ),
                                    create_node_editor(
                                        "output-1",
                                        "output",
                                        "mdi:chart-bar",
                                        "Dashboard",
                                        "Visualization",
                                        default_layout="horizontal",
                                        show_icon=True,
                                    ),
                                ],
                                gap="md",
                            ),
                            span=4,
                        ),
                        # Right panel - Flow canvas
                        dmc.GridCol(
                            dmc.Paper(
                                dash_flows.DashFlows(
                                    id="icon-flow",
                                    nodes=initial_nodes,
                                    edges=initial_edges,
                                    fitView=True,
                                    style={"height": "600px"},
                                    showControls=True,
                                    showMiniMap=True,
                                ),
                                radius="md",
                                withBorder=True,
                                style={"overflow": "hidden"},
                            ),
                            span=8,
                        ),
                    ],
                    gutter="lg",
                ),
            ],
            size="xl",
            py="xl",
        ),
    ],
)


@callback(
    Output("icon-flow", "nodes"),
    Input({"type": "icon-input", "node": ALL}, "value"),
    Input({"type": "title-input", "node": ALL}, "value"),
    Input({"type": "body-input", "node": ALL}, "value"),
    Input({"type": "layout-toggle", "node": ALL}, "value"),
    Input({"type": "show-icon-toggle", "node": ALL}, "checked"),
    State("icon-flow", "nodes"),
    prevent_initial_call=True,
)
def update_nodes(icons, titles, bodies, layouts, show_icons, current_nodes):
    """Update nodes when form values change."""
    if not current_nodes:
        return dash.no_update

    # Map node IDs to their indices in the callback inputs
    node_mapping = {
        "input-1": 0,
        "process-1": 1,
        "output-1": 2,
    }

    # Update each node
    updated_nodes = []
    for node in current_nodes:
        node_id = node["id"]
        if node_id in node_mapping:
            idx = node_mapping[node_id]

            # Determine icon color based on node type
            icon_color = {
                "input": "white",
                "default": "white",
                "output": "white",
            }.get(node.get("type", "default"), "white")

            # Create updated node data
            updated_data = {
                **node.get("data", {}),
                "label": titles[idx] if titles[idx] else node.get("data", {}).get("label", ""),
                "body": bodies[idx] if bodies[idx] else "",
                "layout": layouts[idx] if layouts[idx] else "stacked",
                "showIcon": show_icons[idx],
            }

            # Add icon if provided and showIcon is enabled
            if icons[idx] and show_icons[idx]:
                updated_data["icon"] = DashIconify(
                    icon=icons[idx],
                    width=20,
                    color=icon_color,
                )

            updated_nodes.append({
                **node,
                "data": updated_data,
            })
        else:
            updated_nodes.append(node)

    return updated_nodes


@callback(
    Output({"type": "icon-input", "node": ALL}, "value"),
    Input({"type": "icon-suggestion", "node": ALL, "icon": ALL}, "n_clicks"),
    State({"type": "icon-input", "node": ALL}, "value"),
    prevent_initial_call=True,
)
def handle_icon_suggestion(clicks, current_values):
    """Handle clicking on icon suggestions."""
    if not ctx.triggered_id or not any(clicks):
        return dash.no_update

    # Get the clicked suggestion
    triggered = ctx.triggered_id
    clicked_node = triggered["node"]
    clicked_icon = triggered["icon"]

    # Update the corresponding input
    node_mapping = {"input-1": 0, "process-1": 1, "output-1": 2}

    result = list(current_values)
    if clicked_node in node_mapping:
        result[node_mapping[clicked_node]] = clicked_icon

    return result


@callback(
    Output({"type": "icon-input", "node": ALL}, "disabled"),
    Input({"type": "show-icon-toggle", "node": ALL}, "checked"),
)
def toggle_icon_input_disabled(show_icons):
    """Disable icon input when showIcon is toggled off."""
    return [not checked for checked in show_icons]


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `data.icon` — pass a `DashIconify(icon="mdi:...")` component directly; any name from [icon-sets.iconify.design](https://icon-sets.iconify.design/) works.
- `data.layout` — `"stacked"` renders the icon above the text, `"horizontal"` renders a two-column icon/text layout.
- `data.showIcon` — toggles whether the icon renders at all, letting the same node type serve icon-only, text-only, or full-content variants.
- `data.body` — optional secondary text rendered below the label, for a short description under the title.
- Editing a `dmc.TextInput` / `dmc.SegmentedControl` / `dmc.Switch` with a pattern-matching id (`{"type": ..., "node": node_id}`) triggers a callback that rebuilds the matching node's `data` and writes it back to the flow's `nodes` prop, showing that node content can be fully data-driven.


---

*Source: /nodes*

---

<!-- /persistence — https://flows.2plot.dev/persistence/llms.txt -->

# Save, Restore & Export

> Persist and restore flow state, export to PNG, and copy/paste.

---



### Overview

Serialize the whole graph with `exportFlowState` / `flowState` and rebuild it with `restoreFlowState`. Export the canvas to an image via `downloadImage`, and duplicate elements with `copyAction` / `pasteAction`.

### 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/persistence/demo.py

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

nodes = [{'id': 'load', 'type': 'input', 'data': {'label': 'Load'}, 'position': {'x': 120, 'y': 40}},
 {'id': 'edit', 'type': 'default', 'data': {'label': 'Edit'}, 'position': {'x': 120, 'y': 180}},
 {'id': 'save', 'type': 'output', 'data': {'label': 'Save'}, 'position': {'x': 120, 'y': 320}}]

edges = [{'id': 'le', 'source': 'load', 'target': 'edit', 'animated': True},
 {'id': 'es', 'source': 'edit', 'target': 'save'}]

component = dash_flows.DashFlows(
    id="persistence-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=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>`.

#### Save & Restore

Export the current flow to JSON and restore it later, plus drive the viewport (fit, zoom, focus) from callbacks. Use this pattern for a "save my diagram" button or a session-restore flow.



```python
# File: examples/15_save_restore.py

"""
Example 15: Save and Restore Flow State
=======================================
This example demonstrates how to:
- Export the current flow state to JSON
- Save the state to localStorage or download as file
- Restore a previously saved flow state
- Use viewport actions for programmatic control
"""

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

app = dash.Dash(__name__)

# Initial nodes
initial_nodes = [
    {
        "id": "node-1",
        "type": "input",
        "data": {"label": "Data Source"},
        "position": {"x": 100, "y": 100},
    },
    {
        "id": "node-2",
        "type": "default",
        "data": {"label": "Transform"},
        "position": {"x": 100, "y": 250},
    },
    {
        "id": "node-3",
        "type": "default",
        "data": {"label": "Process"},
        "position": {"x": 300, "y": 250},
    },
    {
        "id": "node-4",
        "type": "output",
        "data": {"label": "Output"},
        "position": {"x": 200, "y": 400},
    },
]

# Initial edges
initial_edges = [
    {"id": "e1-2", "source": "node-1", "target": "node-2"},
    {"id": "e1-3", "source": "node-1", "target": "node-3"},
    {"id": "e2-4", "source": "node-2", "target": "node-4"},
    {"id": "e3-4", "source": "node-3", "target": "node-4"},
]

app.layout = html.Div([
    html.H1("Save and Restore Flow State"),
    html.P("Drag nodes around, add connections, then save or restore the flow state."),

    # Control buttons
    html.Div([
        html.Button("Export State", id="export-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "10px 20px"}),
        html.Button("Download JSON", id="download-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "10px 20px"}),
        html.Button("Restore from Saved", id="restore-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "10px 20px"}),
        html.Button("Reset to Initial", id="reset-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "10px 20px"}),
    ], style={"marginBottom": "10px"}),

    # Viewport action buttons
    html.Div([
        html.Button("Fit View", id="fit-view-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "8px 16px"}),
        html.Button("Zoom In", id="zoom-in-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "8px 16px"}),
        html.Button("Zoom Out", id="zoom-out-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "8px 16px"}),
        html.Button("Focus Node 1", id="focus-node-btn", n_clicks=0,
                   style={"marginRight": "10px", "padding": "8px 16px"}),
    ], style={"marginBottom": "20px"}),

    # Download component
    dcc.Download(id="download-json"),

    # Hidden store for saved state
    dcc.Store(id="saved-state-store"),

    # Status display
    html.Div(id="status-display", style={
        "padding": "10px",
        "marginBottom": "10px",
        "background": "#f0f0f0",
        "borderRadius": "5px",
    }),

    # Flow component
    dash_flows.DashFlows(
        id="flow",
        nodes=initial_nodes,
        edges=initial_edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
    ),

    # JSON preview
    html.H3("Exported State Preview:"),
    html.Pre(id="state-preview", style={
        "padding": "15px",
        "background": "#2d2d2d",
        "color": "#a6e22e",
        "borderRadius": "5px",
        "maxHeight": "300px",
        "overflow": "auto",
        "fontSize": "12px",
    }),
])


# Export state callback
@callback(
    Output("flow", "exportFlowState"),
    Input("export-btn", "n_clicks"),
    prevent_initial_call=True,
)
def trigger_export(n_clicks):
    return True


# Handle exported state
@callback(
    [Output("saved-state-store", "data"),
     Output("state-preview", "children"),
     Output("status-display", "children")],
    Input("flow", "flowState"),
    prevent_initial_call=True,
)
def handle_export(flow_state):
    if flow_state:
        json_str = json.dumps(flow_state, indent=2)
        return (
            flow_state,
            json_str,
            f"State exported! {len(flow_state.get('nodes', []))} nodes, {len(flow_state.get('edges', []))} edges"
        )
    return dash.no_update, dash.no_update, dash.no_update


# Download JSON callback
@callback(
    Output("download-json", "data"),
    Input("download-btn", "n_clicks"),
    State("saved-state-store", "data"),
    prevent_initial_call=True,
)
def download_json(n_clicks, saved_state):
    if saved_state:
        return dict(
            content=json.dumps(saved_state, indent=2),
            filename="flow_state.json",
        )
    return None


# Restore from saved state
@callback(
    Output("flow", "restoreFlowState"),
    Input("restore-btn", "n_clicks"),
    State("saved-state-store", "data"),
    prevent_initial_call=True,
)
def restore_state(n_clicks, saved_state):
    if saved_state:
        return saved_state
    return None


# Reset to initial state
@callback(
    Output("flow", "restoreFlowState", allow_duplicate=True),
    Input("reset-btn", "n_clicks"),
    prevent_initial_call=True,
)
def reset_state(n_clicks):
    return {
        "nodes": initial_nodes,
        "edges": initial_edges,
        "viewport": {"x": 0, "y": 0, "zoom": 1},
    }


# Viewport action callbacks
@callback(
    Output("flow", "viewportAction"),
    [Input("fit-view-btn", "n_clicks"),
     Input("zoom-in-btn", "n_clicks"),
     Input("zoom-out-btn", "n_clicks"),
     Input("focus-node-btn", "n_clicks")],
    prevent_initial_call=True,
)
def handle_viewport_action(fit_clicks, zoom_in_clicks, zoom_out_clicks, focus_clicks):
    ctx = dash.callback_context
    if not ctx.triggered:
        return None

    button_id = ctx.triggered[0]["prop_id"].split(".")[0]

    if button_id == "fit-view-btn":
        return {"action": "fitView", "options": {"padding": 0.2, "duration": 500}}
    elif button_id == "zoom-in-btn":
        return {"action": "zoomIn", "options": {"duration": 300}}
    elif button_id == "zoom-out-btn":
        return {"action": "zoomOut", "options": {"duration": 300}}
    elif button_id == "focus-node-btn":
        return {"action": "focusNode", "nodeId": "node-1", "zoom": 2, "duration": 800}

    return None


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `exportFlowState=True` — asks the flow to serialize its current nodes, edges, and viewport; the result comes back on `flowState`.
- `Input("ex15-flow", "flowState")` — fires once export completes, so you can stash it in a `dcc.Store` and preview it as JSON.
- `restoreFlowState={"nodes": ..., "edges": ..., "viewport": ...}` — rebuilds the flow from a previously saved (or hand-built) state object.
- `Output(..., allow_duplicate=True)` — required whenever a second callback also targets `restoreFlowState`, here used by the "Reset to Initial" button.
- `viewportAction={"action": "fitView" | "zoomIn" | "zoomOut" | "focusNode", "options": {...}}` — triggers programmatic viewport moves without touching nodes or edges.

#### Export Image

Download the flow as a PNG, SVG, or JPEG using html-to-image, with options for background color, quality, and resolution.



```python
# File: examples/18_export_image.py

"""
Example 18: Export Flow as Image
================================
This example demonstrates how to:
- Export the flow as PNG, SVG, or JPEG
- Configure export options (quality, background, resolution)
- Track when downloads complete
"""

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

app = dash.Dash(__name__)

# Sample nodes
nodes = [
    {
        "id": "node-1",
        "type": "input",
        "data": {"label": "Data Input"},
        "position": {"x": 100, "y": 50},
    },
    {
        "id": "node-2",
        "type": "default",
        "data": {"label": "Process"},
        "position": {"x": 100, "y": 200},
    },
    {
        "id": "node-3",
        "type": "default",
        "data": {"label": "Transform"},
        "position": {"x": 300, "y": 200},
    },
    {
        "id": "node-4",
        "type": "output",
        "data": {"label": "Output"},
        "position": {"x": 200, "y": 350},
    },
]

edges = [
    {"id": "e1-2", "source": "node-1", "target": "node-2", "animated": True},
    {"id": "e1-3", "source": "node-1", "target": "node-3"},
    {"id": "e2-4", "source": "node-2", "target": "node-4", "label": "Result"},
    {"id": "e3-4", "source": "node-3", "target": "node-4"},
]

button_style = {
    "padding": "10px 20px",
    "marginRight": "10px",
    "borderRadius": "6px",
    "border": "none",
    "cursor": "pointer",
    "fontWeight": "500",
}

app.layout = html.Div([
    html.H1("Export Flow as Image"),
    html.P("Click a button below to export the flow as an image file."),

    # Export buttons
    html.Div([
        html.Button(
            "Download PNG",
            id="btn-png",
            n_clicks=0,
            style={**button_style, "background": "#4CAF50", "color": "white"}
        ),
        html.Button(
            "Download SVG",
            id="btn-svg",
            n_clicks=0,
            style={**button_style, "background": "#2196F3", "color": "white"}
        ),
        html.Button(
            "Download JPEG",
            id="btn-jpeg",
            n_clicks=0,
            style={**button_style, "background": "#FF9800", "color": "white"}
        ),
        html.Button(
            "High-Res PNG (4x)",
            id="btn-hires",
            n_clicks=0,
            style={**button_style, "background": "#9C27B0", "color": "white"}
        ),
        html.Button(
            "Transparent PNG",
            id="btn-transparent",
            n_clicks=0,
            style={**button_style, "background": "#607D8B", "color": "white"}
        ),
    ], style={"marginBottom": "20px"}),

    # Status display
    html.Div(id="export-status", style={
        "padding": "10px",
        "marginBottom": "10px",
        "background": "#f0f0f0",
        "borderRadius": "5px",
    }, children="Click a button to export the flow"),

    # Flow component
    dash_flows.DashFlows(
        id="flow",
        nodes=nodes,
        edges=edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        backgroundVariant="dots",
    ),
])


# Handle export button clicks
@callback(
    Output("flow", "downloadImage"),
    [Input("btn-png", "n_clicks"),
     Input("btn-svg", "n_clicks"),
     Input("btn-jpeg", "n_clicks"),
     Input("btn-hires", "n_clicks"),
     Input("btn-transparent", "n_clicks")],
    prevent_initial_call=True,
)
def handle_export(png_clicks, svg_clicks, jpeg_clicks, hires_clicks, transparent_clicks):
    ctx = dash.callback_context
    if not ctx.triggered:
        return None

    button_id = ctx.triggered[0]["prop_id"].split(".")[0]

    if button_id == "btn-png":
        return {
            "format": "png",
            "filename": "flow_diagram",
            "backgroundColor": "#ffffff",
        }
    elif button_id == "btn-svg":
        return {
            "format": "svg",
            "filename": "flow_vector",
            "backgroundColor": "#ffffff",
        }
    elif button_id == "btn-jpeg":
        return {
            "format": "jpeg",
            "filename": "flow_compressed",
            "quality": 0.8,
            "backgroundColor": "#ffffff",
        }
    elif button_id == "btn-hires":
        return {
            "format": "png",
            "filename": "flow_highres",
            "pixelRatio": 4,
            "backgroundColor": "#ffffff",
        }
    elif button_id == "btn-transparent":
        return {
            "format": "png",
            "filename": "flow_transparent",
            "backgroundColor": "transparent",
        }

    return None


# Show status when download completes
@callback(
    Output("export-status", "children"),
    [Input("flow", "imageDownloaded"),
     Input("flow", "lastError")],
    prevent_initial_call=True,
)
def update_status(downloaded, error):
    ctx = dash.callback_context
    if not ctx.triggered:
        return dash.no_update

    trigger = ctx.triggered[0]["prop_id"]

    if "imageDownloaded" in trigger and downloaded:
        return f"Downloaded: {downloaded.get('filename', 'unknown')} ({downloaded.get('format', '').upper()})"
    elif "lastError" in trigger and error:
        if error.get("type") == "image-export":
            return f"Export failed: {error.get('message', 'Unknown error')}"

    return dash.no_update


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `downloadImage={"format": "png" | "svg" | "jpeg", "filename": ..., "backgroundColor": ...}` — triggers a browser download of the rendered canvas.
- `pixelRatio` — renders at a higher resolution (e.g. `4` for a 4x PNG) without changing the on-screen layout.
- `backgroundColor="transparent"` — exports a PNG with no background fill, useful for overlaying on other content.
- `Input("ex18-flow", "imageDownloaded")` — fires with the filename and format once the export succeeds.
- `Input("ex18-flow", "lastError")` — reports export failures (checked here for `type == "image-export"`).

#### Copy & Paste

Copy selected nodes and edges to an internal clipboard, then paste them with a position offset — wired to both buttons and Ctrl/Cmd+C / Ctrl/Cmd+V keyboard shortcuts.



```python
# File: examples/19_copy_paste.py

"""
Example 19: Copy and Paste
==========================
This example demonstrates how to:
- Copy selected nodes and edges
- Paste with position offset
- Use keyboard shortcuts for copy/paste
"""

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

app = dash.Dash(__name__)

# Initial nodes
initial_nodes = [
    {
        "id": "node-1",
        "type": "input",
        "data": {"label": "Source A"},
        "position": {"x": 100, "y": 100},
    },
    {
        "id": "node-2",
        "type": "default",
        "data": {"label": "Process"},
        "position": {"x": 100, "y": 250},
    },
    {
        "id": "node-3",
        "type": "output",
        "data": {"label": "Output"},
        "position": {"x": 100, "y": 400},
    },
]

initial_edges = [
    {"id": "e1-2", "source": "node-1", "target": "node-2"},
    {"id": "e2-3", "source": "node-2", "target": "node-3"},
]

button_style = {
    "padding": "10px 20px",
    "marginRight": "10px",
    "borderRadius": "6px",
    "border": "none",
    "cursor": "pointer",
    "fontWeight": "500",
    "fontSize": "14px",
}

app.layout = html.Div([
    html.H1("Copy and Paste Example"),
    html.P([
        "Select nodes (click or shift+drag), then use the buttons or keyboard shortcuts to copy/paste.",
        html.Br(),
        "Tip: Hold Shift and drag to select multiple nodes."
    ]),

    # Control buttons
    html.Div([
        html.Button(
            "Copy Selected (Ctrl+C)",
            id="btn-copy",
            n_clicks=0,
            style={**button_style, "background": "#4CAF50", "color": "white"}
        ),
        html.Button(
            "Paste (Ctrl+V)",
            id="btn-paste",
            n_clicks=0,
            style={**button_style, "background": "#2196F3", "color": "white"}
        ),
        html.Button(
            "Copy All",
            id="btn-copy-all",
            n_clicks=0,
            style={**button_style, "background": "#FF9800", "color": "white"}
        ),
    ], style={"marginBottom": "15px"}),

    # Status display
    html.Div(id="status", style={
        "padding": "10px",
        "marginBottom": "10px",
        "background": "#f0f0f0",
        "borderRadius": "5px",
    }, children="Select nodes and use Copy/Paste"),

    # Flow component
    dash_flows.DashFlows(
        id="flow",
        nodes=initial_nodes,
        edges=initial_edges,
        style={"height": "500px", "border": "1px solid #ddd"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        showBackground=True,
        selectionOnDrag=True,  # Enable drag selection
    ),
])


# Copy button handler
@callback(
    Output("flow", "copyAction"),
    Input("btn-copy", "n_clicks"),
    prevent_initial_call=True,
)
def handle_copy(n_clicks):
    return True


# Copy all button handler
@callback(
    Output("flow", "copyAction", allow_duplicate=True),
    Input("btn-copy-all", "n_clicks"),
    State("flow", "nodes"),
    prevent_initial_call=True,
)
def handle_copy_all(n_clicks, nodes):
    # Deselect all first (to trigger copy all behavior)
    return True


# Paste button handler
@callback(
    Output("flow", "pasteAction"),
    Input("btn-paste", "n_clicks"),
    prevent_initial_call=True,
)
def handle_paste(n_clicks):
    return {"offset": {"x": 100, "y": 50}}


# Update status based on clipboard and paste events
@callback(
    Output("status", "children"),
    [Input("flow", "clipboard"),
     Input("flow", "pastedElements"),
     Input("flow", "selectedNodes")],
)
def update_status(clipboard, pasted, selected):
    status_parts = []

    if selected:
        status_parts.append(f"Selected: {len(selected)} node(s)")
    else:
        status_parts.append("No nodes selected")

    if clipboard and clipboard.get("nodes"):
        status_parts.append(f" | Clipboard: {len(clipboard['nodes'])} node(s)")

    if pasted and pasted.get("nodeIds"):
        status_parts.append(f" | Just pasted: {len(pasted['nodeIds'])} node(s)")

    return "".join(status_parts) if status_parts else "Select nodes and use Copy/Paste"


# Client-side callback for keyboard shortcuts
app.clientside_callback(
    """
    function(id) {
        document.addEventListener('keydown', function(e) {
            // Ctrl+C or Cmd+C
            if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey) {
                // Don't interfere with normal copy in text inputs
                if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;

                e.preventDefault();
                document.getElementById('btn-copy').click();
            }
            // Ctrl+V or Cmd+V
            if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !e.shiftKey) {
                if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;

                e.preventDefault();
                document.getElementById('btn-paste').click();
            }
        });
        return window.dash_clientside.no_update;
    }
    """,
    Output("status", "data-keyboard"),
    Input("status", "id"),
)


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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `copyAction=True` — copies the currently selected nodes/edges (or everything, if nothing is selected) into the flow's internal `clipboard`.
- `pasteAction={"offset": {"x": 100, "y": 50}}` — pastes the clipboard contents, shifting the new copies so they don't sit exactly on top of the originals.
- `selectionOnDrag=True` — lets a shift+drag rectangle select multiple nodes at once, so Copy Selected has something to act on.
- `Input("ex19-flow", "clipboard")` / `Input("ex19-flow", "pastedElements")` — report clipboard contents and the ids of the most recently pasted elements.
- `clientside_callback(...)` — listens for Ctrl/Cmd+C and Ctrl/Cmd+V at the document level and clicks the copy/paste buttons, so keyboard shortcuts work without a server round trip.


---

*Source: /persistence*

---

<!-- /theming — https://flows.2plot.dev/theming/llms.txt -->

# Theming & UI

> Color modes, glass/solid/minimal presets, color schemes, and UI building blocks.

---



### Overview

Switch light/dark with `colorMode` (`light` / `dark` / `system`), choose a `themePreset` (`glass` / `solid` / `minimal`), and pick a `colorScheme` (`default`, `ocean`, `forest`, `sunset`, `midnight`, `rose`). dash-flows pairs naturally with Dash Mantine Components.

### 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/theming/demo.py

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

nodes = [{'id': 't1', 'type': 'input', 'data': {'label': 'Ocean'}, 'position': {'x': 120, 'y': 40}},
 {'id': 't2', 'type': 'default', 'data': {'label': 'Scheme'}, 'position': {'x': 120, 'y': 180}},
 {'id': 't3', 'type': 'output', 'data': {'label': 'Preset'}, 'position': {'x': 120, 'y': 320}}]

edges = [{'id': 't12', 'source': 't1', 'target': 't2', 'animated': True},
 {'id': 't23', 'source': 't2', 'target': 't3'}]

component = dash_flows.DashFlows(
    id="theming-demo",
    nodes=nodes,
    edges=edges,
    style={'border': '1px solid var(--mantine-color-default-border)',
 'borderRadius': '8px',
 'height': '440px'},
    showControls=True,
    showMiniMap=True,
    colorScheme='ocean',
    themePreset='glass',
    fitView=True,
    colorMode='dark',
)
```

    :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>`.

#### Dark Mode with Mantine

Sync dash-flows color mode with a Dash Mantine Components theme. Use the segmented control below to switch the canvas between `light`, `dark`, and `system` — the standalone example additionally flips the whole page's Mantine theme via `forceColorScheme`, which this embedded demo intentionally does not do since the docs page owns its own theme.



```python
# File: examples/11_dark_mode_mantine.py

"""
Example 11: Dark Mode with Mantine Theme Integration
====================================================
This example demonstrates:
- Light/dark mode toggle using Mantine
- CSS variable integration with dash-mantine-components
- Dynamic theme switching
- Persisting theme preference
"""

import dash
from dash import html, Input, Output, callback, clientside_callback
import dash_flows
import dash_mantine_components as dmc
from dash_iconify import DashIconify

app = dash.Dash(__name__)

nodes = [
    {"id": "input-1", "type": "input", "data": {"label": "Data Source", "sublabel": "API"}, "position": {"x": 100, "y": 50}},
    {"id": "default-1", "type": "default", "data": {"label": "Transform", "sublabel": "ETL Process"}, "position": {"x": 100, "y": 180}},
    {"id": "default-2", "type": "default", "data": {"label": "Validate", "sublabel": "Schema Check"}, "position": {"x": 300, "y": 180}},
    {"id": "toolbar-1", "type": "toolbar", "data": {"label": "Review", "sublabel": "Manual Check"}, "position": {"x": 200, "y": 310}},
    {"id": "output-1", "type": "output", "data": {"label": "Database", "sublabel": "PostgreSQL"}, "position": {"x": 200, "y": 440}},
]

edges = [
    {"id": "e1", "source": "input-1", "target": "default-1", "animated": True},
    {"id": "e2", "source": "default-1", "target": "default-2", "type": "smoothstep"},
    {"id": "e3", "source": "default-2", "target": "toolbar-1"},
    {"id": "e4", "source": "toolbar-1", "target": "output-1"},
]

# Theme toggle button in header
header = dmc.Group([
    dmc.Title("DashFlows Dark Mode Demo", order=3),
    dmc.ActionIcon(
        id="theme-toggle",
        variant="outline",
        size="lg",
        radius="md",
        color='yellow',
        children=[
        dmc.Paper(DashIconify(icon="radix-icons:sun", width=25), darkHidden=True),
        dmc.Paper(DashIconify(icon="radix-icons:moon", width=25), lightHidden=True),
    ],
    ),
], justify="space-between", style={"padding": "10px 20px", "borderBottom": "1px solid var(--mantine-color-gray-3)"})

flow_component = dash_flows.DashFlows(
    id="themed-flow",
    nodes=nodes,
    edges=edges,
    style={"height": "500px"},
    fitView=True,
    showControls=True,
    showMiniMap=True,
    backgroundVariant="dots",
)

theme_info = dmc.Paper([
    dmc.Text("Theme Integration Details:", fw=600, mb="xs"),
    dmc.Text([
        "The DashFlows component automatically responds to the ",
        dmc.Code("data-mantine-color-scheme"),
        " attribute set by MantineProvider."
    ], size="sm", mb="xs"),
    dmc.Text([
        "CSS variables defined in ",
        dmc.Code("glass-theme.css"),
        " provide both light and dark mode styling."
    ], size="sm", mb="xs"),
    dmc.List([
        dmc.ListItem("Glass morphism backgrounds adapt to theme"),
        dmc.ListItem("Node colors and shadows change automatically"),
        dmc.ListItem("Edge and handle colors update"),
        dmc.ListItem("Controls and MiniMap match the theme"),
    ], size="sm"),
], p="md", withBorder=True, mt="md")

# Main layout with MantineProvider for theming
app.layout = dmc.MantineProvider(
    id="mantine-provider",
    forceColorScheme="light",
    children=[
        header,
        html.Div([
            flow_component,
            theme_info,
        ], style={"padding": "20px"}),
    ]
)

# Clientside callback for theme toggle using the modern Mantine pattern
clientside_callback(
    """
    (n) => {
        if (!n) return window.dash_clientside.no_update;
        const currentScheme = document.documentElement.getAttribute('data-mantine-color-scheme') || 'light';
        const newScheme = currentScheme === 'light' ? 'dark' : 'light';
        document.documentElement.setAttribute('data-mantine-color-scheme', newScheme);
        return newScheme;
    }
    """,
    Output("mantine-provider", "forceColorScheme"),
    Input("theme-toggle", "n_clicks"),
    prevent_initial_call=True,
)

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

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `colorMode` — set to `"light"`, `"dark"`, or `"system"` on `DashFlows`; the canvas repaints its glass-morphism surfaces, node colors, and edge/handle styling to match.
- A Mantine `SegmentedControl` drives a callback that writes directly to the `colorMode` prop, so the flow can be tied to any Dash-driven theme toggle.
- In the standalone example, a clientside callback instead flips `forceColorScheme` on a page-level `dmc.MantineProvider`, and `DashFlows` picks up the change automatically via the `data-mantine-color-scheme` attribute — no `colorMode` prop needed when the whole page shares one theme.
- CSS variables in `glass-theme.css` provide both light and dark styling for nodes, edges, Controls, and the MiniMap.

#### UI Components

Compose toolbars, panels, and controls around the flow canvas: status-aware nodes, data-carrying edges, animated SVG edges, and a lightweight node search that focuses the viewport. The full app also wires this into a shared control bar; the embedded version below keeps the same nodes, edges, and callbacks with panel spacing trimmed to fit the page.



```python
# File: examples/21_ui_components.py

"""
Example 21: UI Components Demo
Demonstrates the new UI components:
- NodeStatusIndicator (loading/success/error states)
- NodeTooltip (hover tooltips)
- ButtonHandle (clickable handles)
- NodeSearch (find and focus nodes)
- DataEdge (display data on edges)
- AnimatedSvgEdge (animated shapes along edges)
"""

import dash
from dash import html, dcc, callback, Input, Output, State, ctx
from dash.exceptions import PreventUpdate
import dash_flows as df
import dash_mantine_components as dmc
import time
import random

app = dash.Dash(__name__, suppress_callback_exceptions=True)

# Initial nodes demonstrating various features
initial_nodes = [
    # Status indicator demo nodes
    {
        'id': 'status-initial',
        'type': 'default',
        'position': {'x': 50, 'y': 50},
        'data': {
            'label': 'Initial State',
            'status': 'initial',
            'value': 0
        },
    },
    {
        'id': 'status-loading',
        'type': 'default',
        'position': {'x': 250, 'y': 50},
        'data': {
            'label': 'Loading State',
            'status': 'loading',
            'value': 42
        },
    },
    {
        'id': 'status-success',
        'type': 'default',
        'position': {'x': 450, 'y': 50},
        'data': {
            'label': 'Success State',
            'status': 'success',
            'value': 100
        },
    },
    {
        'id': 'status-error',
        'type': 'default',
        'position': {'x': 650, 'y': 50},
        'data': {
            'label': 'Error State',
            'status': 'error',
            'value': -1
        },
    },
    # Data flow demo nodes
    {
        'id': 'data-source',
        'type': 'input',
        'position': {'x': 100, 'y': 250},
        'data': {
            'label': 'Data Source',
            'price': 1299.99,
            'count': 42,
            'status': 'active'
        },
    },
    {
        'id': 'data-processor',
        'type': 'default',
        'position': {'x': 350, 'y': 250},
        'data': {
            'label': 'Processor',
            'multiplier': 2,
            'output': 2599.98
        },
    },
    {
        'id': 'data-output',
        'type': 'output',
        'position': {'x': 600, 'y': 250},
        'data': {
            'label': 'Output',
            'result': 'Complete'
        },
    },
    # Animated edge demo nodes
    {
        'id': 'anim-start',
        'type': 'input',
        'position': {'x': 50, 'y': 450},
        'data': {'label': 'Start'},
    },
    {
        'id': 'anim-circle',
        'type': 'default',
        'position': {'x': 250, 'y': 400},
        'data': {'label': 'Circle Animation'},
    },
    {
        'id': 'anim-arrow',
        'type': 'default',
        'position': {'x': 250, 'y': 500},
        'data': {'label': 'Arrow Animation'},
    },
    {
        'id': 'anim-pulse',
        'type': 'default',
        'position': {'x': 450, 'y': 400},
        'data': {'label': 'Pulse Animation'},
    },
    {
        'id': 'anim-rect',
        'type': 'default',
        'position': {'x': 450, 'y': 500},
        'data': {'label': 'Rect Animation'},
    },
    {
        'id': 'anim-end',
        'type': 'output',
        'position': {'x': 650, 'y': 450},
        'data': {'label': 'End'},
    },
]

# Initial edges demonstrating DataEdge and AnimatedSvgEdge
initial_edges = [
    # Data edges showing values from source nodes
    {
        'id': 'e-data-1',
        'source': 'data-source',
        'target': 'data-processor',
        'type': 'data',
        'data': {
            'key': 'price',
            'prefix': '$',
        },
    },
    {
        'id': 'e-data-2',
        'source': 'data-processor',
        'target': 'data-output',
        'type': 'data',
        'data': {
            'key': 'output',
            'prefix': 'Total: $',
        },
    },
    # Animated SVG edges with different shapes
    {
        'id': 'e-anim-circle',
        'source': 'anim-start',
        'target': 'anim-circle',
        'type': 'animatedSvg',
        'data': {
            'shape': 'circle',
            'duration': 2,
            'size': 5,
            'color': '#3b82f6',
            'count': 2,
        },
    },
    {
        'id': 'e-anim-arrow',
        'source': 'anim-start',
        'target': 'anim-arrow',
        'type': 'animatedSvg',
        'data': {
            'shape': 'arrow',
            'duration': 1.5,
            'size': 6,
            'color': '#10b981',
            'count': 3,
        },
    },
    {
        'id': 'e-anim-pulse',
        'source': 'anim-circle',
        'target': 'anim-pulse',
        'type': 'animatedSvg',
        'data': {
            'shape': 'pulse',
            'duration': 2.5,
            'size': 4,
            'color': '#f59e0b',
            'count': 2,
        },
    },
    {
        'id': 'e-anim-rect',
        'source': 'anim-arrow',
        'target': 'anim-rect',
        'type': 'animatedSvg',
        'data': {
            'shape': 'rect',
            'duration': 2,
            'size': 4,
            'color': '#8b5cf6',
            'count': 2,
        },
    },
    {
        'id': 'e-anim-end-1',
        'source': 'anim-pulse',
        'target': 'anim-end',
        'type': 'animatedSvg',
        'data': {
            'shape': 'circle',
            'duration': 1.5,
            'size': 6,
            'color': '#ef4444',
            'count': 1,
            'reverse': False,
        },
    },
    {
        'id': 'e-anim-end-2',
        'source': 'anim-rect',
        'target': 'anim-end',
        'type': 'animatedSvg',
        'data': {
            'shape': 'arrow',
            'duration': 1.5,
            'size': 5,
            'color': '#ec4899',
            'count': 2,
        },
    },
    # Status indicator connections
    {
        'id': 'e-status-1',
        'source': 'status-initial',
        'target': 'status-loading',
        'type': 'animatedSvg',
        'data': {
            'shape': 'circle',
            'duration': 1,
            'size': 4,
            'color': '#64748b',
            'count': 1,
        },
    },
    {
        'id': 'e-status-2',
        'source': 'status-loading',
        'target': 'status-success',
        'type': 'animatedSvg',
        'data': {
            'shape': 'pulse',
            'duration': 1,
            'size': 4,
            'color': '#3b82f6',
            'count': 2,
        },
    },
    {
        'id': 'e-status-3',
        'source': 'status-loading',
        'target': 'status-error',
        'type': 'animatedSvg',
        'data': {
            'shape': 'arrow',
            'duration': 1,
            'size': 4,
            'color': '#ef4444',
            'count': 1,
        },
    },
]

app.layout = dmc.MantineProvider(
    html.Div([
        # Header
        html.Div([
            html.H2("UI Components Demo", style={'margin': 0}),
            html.P("Testing NodeStatusIndicator, DataEdge, AnimatedSvgEdge, and more",
                   style={'margin': '5px 0 0 0', 'color': '#666'}),
        ], style={'padding': '15px 20px', 'borderBottom': '1px solid #e2e8f0'}),

        # Control Panel
        html.Div([
            # Node Search Toggle
            dmc.Button(
                "Toggle Search (Ctrl+F)",
                id="toggle-search",
                variant="light",
                color="blue",
                size="sm",
            ),

            # Status simulation controls
            dmc.Button(
                "Simulate Loading",
                id="btn-simulate-loading",
                variant="light",
                color="orange",
                size="sm",
                style={'marginLeft': '10px'},
            ),

            # Data update controls
            dmc.Button(
                "Update Data Values",
                id="btn-update-data",
                variant="light",
                color="green",
                size="sm",
                style={'marginLeft': '10px'},
            ),

            # Fit view
            dmc.Button(
                "Fit View",
                id="btn-fit-view",
                variant="light",
                color="gray",
                size="sm",
                style={'marginLeft': '10px'},
            ),

            # Animation speed control
            html.Div([
                html.Label("Animation Speed:", style={'marginRight': '10px', 'fontSize': '14px'}),
                dmc.SegmentedControl(
                    id="animation-speed",
                    value="normal",
                    data=[
                        {"value": "slow", "label": "Slow"},
                        {"value": "normal", "label": "Normal"},
                        {"value": "fast", "label": "Fast"},
                    ],
                    size="xs",
                ),
            ], style={'display': 'flex', 'alignItems': 'center', 'marginLeft': '20px'}),

        ], style={
            'padding': '10px 20px',
            'display': 'flex',
            'alignItems': 'center',
            'borderBottom': '1px solid #e2e8f0',
            'backgroundColor': '#f8fafc',
        }),

        # Node Search Panel (hidden by default)
        html.Div(
            id="search-panel",
            children=[
                dmc.TextInput(
                    id="node-search-input",
                    placeholder="Search nodes by label or ID...",
                    style={'width': '300px'},
                    leftSection=html.Span("🔍"),
                ),
                html.Div(id="search-results", style={'marginTop': '10px'}),
            ],
            style={
                'display': 'none',
                'position': 'absolute',
                'top': '130px',
                'left': '50%',
                'transform': 'translateX(-50%)',
                'zIndex': 1000,
                'padding': '15px',
                'backgroundColor': 'rgba(255, 255, 255, 0.95)',
                'backdropFilter': 'blur(10px)',
                'borderRadius': '12px',
                'boxShadow': '0 8px 32px rgba(0, 0, 0, 0.15)',
                'border': '1px solid rgba(255, 255, 255, 0.5)',
            },
        ),

        # Flow Container
        html.Div([
            df.DashFlows(
                id='flow',
                nodes=initial_nodes,
                edges=initial_edges,
                style={'height': '600px'},
                fitView=True,
                showMiniMap=True,
                showControls=True,
                showBackground=True,
                colorMode='light',
                themePreset='glass',
            ),
        ], style={'position': 'relative'}),

        # Info Panel
        html.Div([
            html.H4("Component Features:", style={'margin': '0 0 10px 0'}),
            dmc.Grid([
                dmc.GridCol([
                    html.Div([
                        html.Strong("DataEdge"),
                        html.P("Displays data from source node on the edge. "
                               "See 'price' and 'output' values on the middle row edges.",
                               style={'fontSize': '13px', 'margin': '5px 0 0 0', 'color': '#666'}),
                    ]),
                ], span=4),
                dmc.GridCol([
                    html.Div([
                        html.Strong("AnimatedSvgEdge"),
                        html.P("Animated shapes traveling along edges: circle, arrow, pulse, rect. "
                               "Watch the bottom section for all animation types.",
                               style={'fontSize': '13px', 'margin': '5px 0 0 0', 'color': '#666'}),
                    ]),
                ], span=4),
                dmc.GridCol([
                    html.Div([
                        html.Strong("Node Status"),
                        html.P("Top row shows different states. Click 'Simulate Loading' to see "
                               "the status transition through states.",
                               style={'fontSize': '13px', 'margin': '5px 0 0 0', 'color': '#666'}),
                    ]),
                ], span=4),
            ]),
        ], style={
            'padding': '15px 20px',
            'borderTop': '1px solid #e2e8f0',
            'backgroundColor': '#f8fafc',
        }),

        # Store for search state
        dcc.Store(id='search-open', data=False),

    ], style={'fontFamily': 'system-ui, -apple-system, sans-serif'}),
)


# Toggle search panel
@callback(
    [Output('search-panel', 'style'),
     Output('search-open', 'data')],
    Input('toggle-search', 'n_clicks'),
    State('search-open', 'data'),
    State('search-panel', 'style'),
    prevent_initial_call=True,
)
def toggle_search(n_clicks, is_open, current_style):
    if not n_clicks:
        raise PreventUpdate

    new_style = {**current_style}
    new_style['display'] = 'none' if is_open else 'block'
    return new_style, not is_open


# Search nodes
@callback(
    [Output('search-results', 'children'),
     Output('flow', 'viewportAction', allow_duplicate=True)],
    Input('node-search-input', 'value'),
    State('flow', 'nodes'),
    prevent_initial_call=True,
)
def search_nodes(search_term, nodes):
    if not search_term or len(search_term) < 2:
        return [], dash.no_update

    term = search_term.lower()
    matches = []

    for node in nodes:
        node_id = node['id'].lower()
        label = node.get('data', {}).get('label', '').lower()

        if term in node_id or term in label:
            matches.append(node)

    if not matches:
        return html.Div("No nodes found", style={'color': '#666', 'padding': '10px'}), dash.no_update

    results = []
    for node in matches[:5]:  # Limit to 5 results
        results.append(
            dmc.Button(
                f"{node['data'].get('label', node['id'])} ({node['id']})",
                id={'type': 'search-result', 'id': node['id']},
                variant="subtle",
                fullWidth=True,
                size="sm",
                style={'marginBottom': '5px', 'justifyContent': 'flex-start'},
            )
        )

    return results, dash.no_update


# Focus on searched node
@callback(
    Output('flow', 'viewportAction'),
    Input({'type': 'search-result', 'id': dash.ALL}, 'n_clicks'),
    prevent_initial_call=True,
)
def focus_search_result(n_clicks):
    if not any(n_clicks):
        raise PreventUpdate

    triggered = ctx.triggered_id
    if triggered and isinstance(triggered, dict):
        node_id = triggered['id']
        return {
            'action': 'focusNode',
            'nodeId': node_id,
            'zoom': 1.5,
            'duration': 500,
        }

    raise PreventUpdate


# Fit view button
@callback(
    Output('flow', 'viewportAction', allow_duplicate=True),
    Input('btn-fit-view', 'n_clicks'),
    prevent_initial_call=True,
)
def fit_view(n_clicks):
    if not n_clicks:
        raise PreventUpdate
    return {'action': 'fitView', 'options': {'padding': 0.2, 'duration': 500}}


# Simulate loading states
@callback(
    Output('flow', 'nodes', allow_duplicate=True),
    Input('btn-simulate-loading', 'n_clicks'),
    State('flow', 'nodes'),
    prevent_initial_call=True,
)
def simulate_loading(n_clicks, nodes):
    if not n_clicks:
        raise PreventUpdate

    # Cycle through states
    states = ['initial', 'loading', 'success', 'error']

    updated_nodes = []
    for node in nodes:
        if node['id'].startswith('status-'):
            current_status = node['data'].get('status', 'initial')
            current_idx = states.index(current_status) if current_status in states else 0
            next_idx = (current_idx + 1) % len(states)

            updated_node = {
                **node,
                'data': {
                    **node['data'],
                    'status': states[next_idx],
                }
            }
            updated_nodes.append(updated_node)
        else:
            updated_nodes.append(node)

    return updated_nodes


# Update data values (to show DataEdge updates)
@callback(
    Output('flow', 'nodes', allow_duplicate=True),
    Input('btn-update-data', 'n_clicks'),
    State('flow', 'nodes'),
    prevent_initial_call=True,
)
def update_data_values(n_clicks, nodes):
    if not n_clicks:
        raise PreventUpdate

    updated_nodes = []
    for node in nodes:
        if node['id'] == 'data-source':
            new_price = round(random.uniform(500, 2000), 2)
            updated_node = {
                **node,
                'data': {
                    **node['data'],
                    'price': new_price,
                    'count': random.randint(10, 100),
                }
            }
            updated_nodes.append(updated_node)
        elif node['id'] == 'data-processor':
            # Find source price
            source_price = 1299.99
            for n in nodes:
                if n['id'] == 'data-source':
                    source_price = n['data'].get('price', 1299.99)
                    break

            multiplier = random.choice([1.5, 2, 2.5, 3])
            updated_node = {
                **node,
                'data': {
                    **node['data'],
                    'multiplier': multiplier,
                    'output': round(source_price * multiplier, 2),
                }
            }
            updated_nodes.append(updated_node)
        else:
            updated_nodes.append(node)

    return updated_nodes


# Update animation speed
@callback(
    Output('flow', 'edges'),
    Input('animation-speed', 'value'),
    State('flow', 'edges'),
    prevent_initial_call=True,
)
def update_animation_speed(speed, edges):
    if not speed:
        raise PreventUpdate

    speed_multipliers = {
        'slow': 2.0,
        'normal': 1.0,
        'fast': 0.5,
    }

    multiplier = speed_multipliers.get(speed, 1.0)
    base_durations = {
        'e-anim-circle': 2,
        'e-anim-arrow': 1.5,
        'e-anim-pulse': 2.5,
        'e-anim-rect': 2,
        'e-anim-end-1': 1.5,
        'e-anim-end-2': 1.5,
        'e-status-1': 1,
        'e-status-2': 1,
        'e-status-3': 1,
    }

    updated_edges = []
    for edge in edges:
        if edge['type'] == 'animatedSvg' and edge['id'] in base_durations:
            updated_edge = {
                **edge,
                'data': {
                    **edge.get('data', {}),
                    'duration': base_durations[edge['id']] * multiplier,
                }
            }
            updated_edges.append(updated_edge)
        else:
            updated_edges.append(edge)

    return updated_edges


if __name__ == '__main__':
    app.run(debug=True, port=8095)
```

    :defaultExpanded: false
    :withExpandedButton: true

**How it works**

- `data.status` — `"initial"` / `"loading"` / `"success"` / `"error"` on a node's `data` renders the built-in status indicator; a button callback cycles the top-row nodes through each state.
- `type: "data"` edges — read a `data.key` field (with an optional `prefix`) straight off the source node and render it as an edge label, so the label updates whenever the source node's data changes.
- `type: "animatedSvg"` edges — animate a `shape` (`circle`, `arrow`, `pulse`, `rect`) along the edge path; `duration`, `size`, `color`, and `count` control the animation, and a `SegmentedControl` rescales every edge's `duration` at once.
- `viewportAction: {'action': 'focusNode', ...}` — used by both the "Fit View" button and the search-result buttons to pan/zoom the canvas to a specific node.
- A pattern-matching `Input({'type': 'ex21-search-result', 'id': dash.ALL}, 'n_clicks')` callback reads `ctx.triggered_id` to know which search result was clicked.


---

*Source: /theming*
