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

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

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

# 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

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.

# 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

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

# 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

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

# 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

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.

# 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


Source: /interactions

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