Interactive graph visualization for Python notebooks.
Works with Marimo, Jupyter, VS Code, Colab, anywhere anywidget runs.
- Universal: One widget, every notebook environment
- Backend-agnostic: Grafeo, Neo4j, NetworkX, pandas, or raw dicts
- Interactive: Pan, zoom, click, drag, pin, expand neighbors, box select
- Customizable: Colors, sizes, layouts, dark mode
- Exportable: HTML, JSON
uv add anywidget-graphOptional extras:
uv add "anywidget-graph[networkx]" # NetworkX support
uv add "anywidget-graph[pandas]" # pandas support
uv add "anywidget-graph[grafeo]" # Grafeo backend (grafeo 0.6)
uv add "anywidget-graph[cosmosdb]" # CosmosDB / Gremlin support
uv add "anywidget-graph[neo4j]" # Neo4j Python driver (Neo4jBackend)
uv add "anywidget-graph[arango]" # ArangoDB (ArangoBackend)
uv add "anywidget-graph[examples]" # marimo, to run the apps in examples/Requires Python 3.12 or newer. The Grafeo backends need grafeo 0.6: the grafeo extra for embedded mode, grafeo-server 0.6 for server mode, and WASM mode loads @grafeo-db/wasm 0.6 in the browser. anywidget-graph 0.4.x is the line for grafeo 0.5.
from anywidget_graph import Graph
graph = Graph.from_dict(
{
"nodes": [
{"id": "alice", "label": "Alice", "group": "person"},
{"id": "bob", "label": "Bob", "group": "person"},
{"id": "paper", "label": "Graph Theory", "group": "document"},
],
"edges": [
{"source": "alice", "target": "bob", "label": "knows"},
{"source": "alice", "target": "paper", "label": "authored"},
],
}
)
graphgraph = Graph.from_dict({"nodes": [{"id": "a"}, {"id": "b"}], "edges": [{"source": "a", "target": "b"}]})graph = Graph(
nodes=[{"id": "a", "label": "Alice"}, {"id": "b", "label": "Bob"}],
edges=[{"source": "a", "target": "b", "label": "KNOWS"}],
)from neo4j import GraphDatabase
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
with driver.session() as session:
result = session.run("MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100")
graph = Graph.from_cypher(result)graph = Graph.from_gql(result)from rdflib import Graph as RDFGraph
g = RDFGraph()
g.parse("data.ttl")
result = g.query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
graph = Graph.from_sparql(result)graph = Graph.from_gremlin(result)graph = Graph.from_graphql(
response.json(),
nodes_path="data.characters.results",
id_field="id",
label_field="name",
)import networkx as nx
G = nx.karate_club_graph()
graph = Graph.from_networkx(G)import pandas as pd
nodes_df = pd.DataFrame({"id": ["alice", "bob"], "group": ["person", "person"]})
edges_df = pd.DataFrame({"source": ["alice"], "target": ["bob"], "weight": [1.0]})
graph = Graph.from_dataframe(nodes_df, edges_df)graph = Graph.from_dict(data)
@graph.on_node_click
def handle_node(node_id, node_data):
print(f"Clicked: {node_id}")
@graph.on_edge_click
def handle_edge(edge_data):
print(f"Edge: {edge_data['label']}")
@graph.on_selection
def handle_selection(node_ids):
print(f"Selected: {node_ids}")graph.selected_nodes # Current selection (list of IDs)
graph.selection_mode = "box" # Switch to box-select modegraph.expand_node("alice") # Fetch and merge neighbors (requires backend)graph.pin_nodes(["alice", "bob"]) # Pin at current positions
graph.unpin_nodes(["alice"]) # Release back to layout
graph.toggle_pin("bob") # Toggle pin state
graph.unpin_all() # Unpin everythinggraph.clear() # Remove all nodes, edges, pins, and selectionShow two graphs side by side in one canvas, each laid out in its own lane, with curved edges between them, and grow them while a process runs:
graph = Graph(
lanes=[{"id": "source", "title": "Source graph"}, {"id": "target", "title": "Target graph"}],
nodes=[{"id": "a", "lane": "source"}, {"id": "b", "lane": "source"}, {"id": "x", "lane": "target"}],
edges=[{"source": "a", "target": "b"}, {"source": "x", "target": "a", "cross": True}],
)
graph.append(nodes=[{"id": "y", "lane": "target"}], edges=[{"source": "y", "target": "b"}])
graph.remove(nodes=["x"]) # fades out, nothing else moves
graph.pulse_nodes = ["b"] # a soft pulse on these nodes; [] stops it
graph.theme = {
"background": "#0d1416",
"panel": "#121b1e",
"text": "#dbe7e5",
"muted": "#8ea3a3",
"border": "#1e2b2f",
"accent": "#5bb8a9",
}lanes: lanes left to right; a node'slanepicks one (a node without a known lane goes to the first). Edges between lanes (or markedcross) are drawn as soft curves above both lanes and light up when either end is hovered or selected; a search also shows a match's partners in the other lanes. Withoutlanesthe widget behaves as before.append(nodes, edges): adds items without a re-layout; existing nodes keep their positions, a new node is placed at the height of its neighbours in other lanes, where nodes of the same neighbours stack as rows one label line apart with their labels shown (or next to its neighbours in its lane; without lanes, next to its neighbours inside the area already drawn). The items are also merged intonodesandedges, soto_json(),to_html()and a widget shown again include them (a host that setsappend_batchitself gets the merged lists written back by the widget). Changinglanesor a style keeps appended items; settingnodesoredgesto other data replaces everything drawn. New items fade in and edges draw from source to target,append_stagger_ms(default 60) apart; a large batch is kept under 1.5 s.append_animation="none"turns the animation off.remove(nodes, edges): takes nodes (with every edge touching them) and edges out without a re-layout; they fade out and leavenodesandedges. An edge is{"source", "target"}(every edge between those ends) or with a"label"(that edge only). A host that setsappend_batchitself puts them underremove:{"seq": n, "nodes": [...], "edges": [...], "remove": {"nodes": [ids], "edges": [...]}}; removals come first, so one batch can re-sample (an item removed and sent again stays where it is).- Lane options:
widthis a lane's share of the standard width (default 1);arrange: "column"stacks the lane's nodes in the middle of its band (for a few roots, such as repositories, whose edges then fan out into the next lane);action: Truemakes a lane without nodes that shows a button, with the host'sicon(SVG markup, wherecurrentColortakes the accent colour, or an image URL), orglyph(a list of colours drawn as stacked bars), else a neutral "open" icon;captionis text under it. An edge whose end is an action lane's id (and no node) is drawn as a curve into its button, like the cross-lane curves. The camera frames all lanes, and the lanes stretch to the canvas's shape. - Lane layout options (all off by default; a lane without them is laid out as before):
layout: "force": a force layout inside the lane: nodes repel and do not overlap, edges inside the lane are springs, and a node is pulled towards the height of its partners in the lanes to its left. Nodes never leave their lane. A crowded lane draws its nodes smaller so the layout has room. Appended nodes settle from where they land while the older nodes stay put. Deterministic: the same data gives the same picture.rows: {"field": "layer", "order": ["business", "application", "technology"]}: splits the lane into rows, top to bottom, by a node field; a node with another value (or none) goes to an extra row at the bottom. Row heights follow the node counts (with a minimum), each row shows its name and a faint separator, and nodes stay inside their row. Implieslayout: "force".labels: {"count": 12}: at the default zoom, shows the labels of the lane's 12 largest nodes (by size, then degree), and no others (hover or select a node to see its label; zoomed in or out, sigma's label grid decides). A label may cover smaller nodes, on a soft backing so it stays readable, but never a node as large, a labelled node or another label; one that would cross the lane's right edge goes on the left of its node, and a long name is shortened with an ellipsis (hover shows it whole).relayout: 0.33: when one batch removes or adds more than this share of the lane's nodes (a re-sample), the lane is laid out again as a whole and its nodes glide to their new places; smaller batches keep older nodes where they are.
lane_action: set to{"lane": <id>, "seq": <n>}when an action lane's glyph is clicked, so the host can respond (open a panel, switch a view).seqcounts on from the value the model holds, so every click is a change, also in a widget shown twice.pulse_nodes: ids that pulse softly.type_colors: colours per type,{"nodes": {"<type>": "#hex"}, "edges": {"<type>": "#hex"}}(types as the schema panel groups them). They colour the items of that type and the legend swatches; an item's owncolorstill wins, and other types keep the palette.totals: for a host that sends a sample of a larger graph,{"nodes": {"<type>": n}, "edges": {"<type>": n}}(types as the schema panel groups them: a node's first label, an edge's type). The count badge and the schema panel show "shown / total"; a type the sample lacks shows as 0, a type without a total shows its count (and counts as shown in full).theme: host colours (background,panel,text,muted,border,accent) that replace the widget's light and dark defaults; a key left out (ortheme = {}) brings the default back._features: the widget sets the capabilities it supports when it renders, so a host can detect an older build.
graph = Graph.from_dict(
data,
color_field="group", # Color nodes by field
color_scale="viridis", # Scale: viridis, plasma, inferno, magma, cividis, turbo
size_field="score", # Size nodes by field
size_range=[5, 30], # Min/max node size
)graph.edge_color_field = "type"
graph.edge_color_scale = "plasma"
graph.edge_size_field = "weight"
graph.edge_size_range = [1, 8]A node or edge can carry its own style; each field is optional and wins over the field mappings above. Changing them on items with the same ids restyles in place, without a new layout.
nodes = [
{"id": "a", "opacity": 0.45, "badge": "2/3"}, # faded, with a small badge
{"id": "b", "ring": "#e3a33b", "ring_width": 3}, # a ring around the node
{"id": "c", "dash": True}, # a dashed outline
]
edges = [
{"source": "a", "target": "b", "dash": "4 3", "width": 1}, # dashed, 1 px
{"source": "b", "target": "c", "width": 3, "opacity": 0.5, "color": "#888"},
]- Node:
opacity(0 to 1),ring(a colour),ring_width(px, default 2),badge(short text),badge_color,dash(True,"4 3"or[4, 3]). - Edge:
opacity,width(px),dash,color. - Opacity mixes the colour towards the canvas (the renderer draws colours opaque), so it reads the same on a light and a dark canvas. A pinned node shows an amber ring.
Show a large graph one level of detail at a time. Each node holds the highest level it survives in level_field; the widget shows nodes at or above current_level, with chips over the canvas that show each level's count and switch the level. Nodes without a level always show (so levels can apply to one lane), and switching levels moves nothing.
graph = Graph(
nodes=nodes, # each with "level": 0..3
edges=edges,
levels=[
{"id": 0, "label": "All", "count": 5230},
{"id": 1, "label": "Derivation inputs", "count": 1031},
{"id": 2, "label": "After filters", "count": 412},
{"id": 3, "label": "Sources used", "count": 98},
],
current_level=2,
filtered_style="dim", # the level just below is dimmed; "hide" hides it
)The counts come from the host (the whole graph need not be loaded); edges to a hidden node are hidden.
Graph.from_dict(data, layout="force") # ForceAtlas2 (default)
Graph.from_dict(data, layout="circular")
Graph.from_dict(data, layout="random")
Graph(nodes=[{"id": "a", "x": 0, "y": 0}, {"id": "b", "x": 10, "y": 5}], layout="preset") # The nodes' own x and ygraph = Graph(
nodes=nodes,
edges=edges,
width=800, # Widget width (px)
height=600, # Widget height (px)
fill=False, # Take the host's size and follow resizes (ignores width/height)
background="#fafafa", # Background color
show_labels=True, # Node labels
show_edge_labels=False, # Edge labels
edge_arrows=False, # Edges as arrows from source to target
highlight_neighbors=False, # A selected node keeps its neighbours and their edges lit
show_toolbar=True, # Toolbar visibility
show_settings=True, # Settings panel
show_query_input=True, # Query input box
dark_mode=None, # None follows the page's light or dark theme; True or False keeps that theme
show_tooltip=True, # Hover tooltips
tooltip_fields=["label", "id"],
max_nodes=300, # Limit for node expansion
max_query_nodes=None, # Cut a query result to its N best-connected nodes (None draws every node)
)Grafeo runs in one of three modes, picked with grafeo_connection_mode or in the settings panel.
Embedded (Python, the default): queries run in the notebook kernel. Requires the grafeo extra (grafeo~=0.6.0).
import grafeo
db = grafeo.GrafeoDB()
graph = Graph(database_backend="grafeo", grafeo_db=db, query_language="gql")Server: the browser talks to grafeo-server over HTTP (POST /query, GET /db/{name}/schema). The server must allow the notebook's origin, for example grafeo-server --cors-origins http://localhost:8888.
graph = Graph(database_backend="grafeo", grafeo_connection_mode="server", grafeo_server_url="http://localhost:7474")WASM: the engine runs in the browser. @grafeo-db/wasm (0.6.0) is downloaded from jsDelivr when you click "Initialize WASM", so nothing extra is installed in Python.
graph = Graph(database_backend="grafeo", grafeo_connection_mode="wasm", query_language="gql")Query languages: GQL, Cypher, Gremlin, GraphQL and SPARQL. Nodes and edges are taken from every result column, including lists and maps (collect(n), variable-length relationships, nodes(p), [n, m], {k: n}) and paths (RETURN p); scalar columns are ignored. Run one statement per query: Grafeo rejects several ;-separated statements in one call.
Property values reach the widget as JSON: a GQL number with a point (1.50), which grafeo 0.6 returns as an exact decimal, becomes a number (so size_field and color_field can use it), and dates and times become their ISO text.
grafeo 0.6 stores every database as a single file in a new format. The first time 0.6 opens a 0.5.x database for writing (grafeo.GrafeoDB(path)), it migrates it and keeps the old file next to it as <path>.pre-0.6 (a 0.5.x WAL directory becomes a file at the same path, the directory is kept as <path>.pre-0.6/). 0.5.x cannot open the migrated database, so stop every 0.5.x process that uses it first, and keep the .pre-0.6 copy as the way back. Opening a directory that is not a 0.5.x database (an empty one included) now fails: pass a file path such as data/graph.grafeo. See grafeo's Upgrading to 0.6 for the query changes (for example a WHERE right after INSERT or DELETE is now an error, GQL sorts nulls last, and integer overflow or division by zero fails the query).
Try it without any setup:
from anywidget_graph.demo import demo_graph
demo_graph() # WASM mode with a small movie graph loadedgraph = Graph(
database_backend="neo4j",
connection_uri="neo4j+s://demo.neo4jlabs.com",
connection_username="neo4j",
connection_password="password",
)The browser connects to Neo4j itself, so the username and password are widget settings that reach the browser: anyone who can open the notebook page can read them, and a notebook saved with its widget state keeps them in the file. In a shared or published notebook, use a read-only database user, or query from Python (Neo4j's Python driver, then Graph.from_cypher(result)) so the credentials stay in the kernel.
graph = Graph(backend=my_backend) # Any object implementing DatabaseBackend protocolThe widget knows graphs, not your domain. The host (a notebook, or an app that mounts the widget's front end) decides how its data looks and behaves, and the widget's own defaults stay neutral:
- Looks: colours per type (
type_colors), the host's colours (theme), action lane icons (icon,glyph), lane titles and captions. - What to show: when a graph is too large to draw whole, the host picks the sample and sends it, with the totals behind it (
totals); the widget never makes counts up.append()andremove()keep the drawing up to date without a re-layout. - Layout per lane:
width,arrange,layout,rows,relayoutandlabelsare lane options the host sets.
New behaviour is opt-in and off by default: an update does not change a view that does not ask for it. _features lists what a build supports, so a host can tell an older build apart and fall back.
An app can mount the bundled front end (anywidget_graph.ui.get_esm() and get_css()) with any model object that has get, set, on, off, save_changes and send, as anywidget's model does.
- The host sets
nodes,edges,lanes,append_batch,pulse_nodes,theme,type_colors,totals,dark_mode(with_theme_explicit: trueto keep it; without, the widget follows the page's light or dark theme and reports it indark_mode), and the size (widthandheight, orfill). - The widget sets
selected_node,selected_nodes,selected_edge,hovered_node,lane_actionand_features.selected_node,hovered_nodeandselected_edgehold the item's own fields plus how it is drawn (labelas displayed,x,y,size,color). After anappend_batchit writes the mergednodesandedgesback, unless they already hold the batch (asGraph.append()sends them). - A setting the host leaves out reads as the widget's default, the same as a fresh Python
Graph()has it. - The widget sends no query when it mounts: the first view is the host's.
- A query's error is in
query_error;query_noteholds what a finished query tells besides its result (a result without nodes or edges keeps the drawing and the note says why; a cut atmax_nodes). In the browser-side modes the first query connects with the configured settings, and each widget keeps its own connection (two widgets on a page can use two servers). - In server mode the widget connects to
grafeo_server_urlon its first query (or Connect) and expects grafeo-server's responses:GET /health,GET /db/{connection_database}/schema, andPOST /query(body{"query", "language", "database"},databaseleft out fordefault) answering{"columns": [...], "rows": [[value, ...], ...]}, where a node is{"_id", "_labels", ...properties}and an edge{"_id", "_type", "_source", "_target", ...properties}(properties flat on the entity). A host that serves these endpoints itself sends the same shapes.
graph.to_json() # JSON string with nodes and edges
graph.to_html() # Self-contained HTML string
graph.to_html(title="My Graph") # Custom title
graph.save_html("graph.html") # Write HTML to file| Environment | Supported |
|---|---|
| Marimo | Yes |
| JupyterLab | Yes |
| Jupyter Notebook | Yes |
| VS Code | Yes |
| Google Colab | Yes |
| Databricks | Yes |
uv sync --all-extras --all-groups
uv run pytest # Python tests, and the Node tests in tests/jsThe front end is written as ES modules in src/anywidget_graph/ui/; the widget loads them bundled into ui/dist/widget.js. After editing them, rebuild the bundle (CI fails on a stale one):
cd scripts/vendor
npm ci
npm run build:esm # npm run check:esm only checksBrowser tests (Playwright and Chromium) run the bundle in a real page:
uv run --with playwright python -m playwright install chromium
uv run --with playwright pytest tests/e2e- anywidget, custom Jupyter widgets made easy
- Grafeo, embeddable graph database
- grafeo-web, Grafeo in the browser
- Playground, interactive browser playground for Grafeo
Apache-2.0
