Skip to content
This is the alpha v4 version website. Looking for the v3 documentation?

Hover and search highlight

The most common interactive pattern in graph visualization: highlight a node and its neighbors on hover, and let users search for nodes by label.

Live example

Hover any node to highlight its neighborhood. Use the search field to filter nodes by label.

import Graph from "graphology";
import Sigma from "sigma";
import type { Coordinates } from "sigma/types";
import data from "../_data/data.json";
const container = document.getElementById("sigma-container") as HTMLElement;
const searchInput = document.getElementById("search-input") as HTMLInputElement;
const searchSuggestions = document.getElementById("suggestions") as HTMLDataListElement;
const graph = new Graph();
graph.import(data);
const GREY = "#f6f6f6";
const renderer = new Sigma(graph, container, {
customNodeState: {
isActive: false,
},
customEdgeState: {
isActive: false,
},
customGraphState: {
hasActiveSubgraph: false,
},
primitives: {
depthLayers: ["edges", "nodes", "topEdges", "topNodes", "activeNodes"],
},
styles: {
nodes: [
{
color: { attribute: "color" },
size: { attribute: "size" },
label: { attribute: "label" },
},
{
whenState: "isHighlighted",
then: { labelVisibility: "visible" },
},
{
when: (_attrs, state, graphState) => graphState.hasActiveSubgraph && !state.isActive && !state.isHighlighted,
then: { color: GREY, label: "" },
},
{
whenState: "isActive",
then: { depth: "topNodes", labelVisibility: "visible" },
},
{
whenState: "isHovered",
then: { depth: "activeNodes", labelVisibility: "visible" },
},
],
edges: [
{
color: { attribute: "color", defaultValue: "#ccc" },
size: { attribute: "size", defaultValue: 1 },
},
{
when: (_attrs, state, graphState) => graphState.hasActiveSubgraph && !state.isActive,
then: { color: GREY },
},
{
whenState: "isActive",
then: { depth: "topEdges" },
},
],
},
});
searchSuggestions.innerHTML = graph
.nodes()
.map((node) => `<option value="${graph.getNodeAttribute(node, "label")}"></option>`)
.join("\n");
let hoveredNode: string | null = null;
let activeSearchNodes: Set<string> | null = null;
function setActiveSubgraph(activeNodes: Set<string> | null) {
graph.forEachNode((node) => {
renderer.setNodeState(node, { isActive: !!activeNodes && activeNodes.has(node) });
});
graph.forEachEdge((edge) => {
const [source, target] = graph.extremities(edge);
renderer.setEdgeState(edge, {
isActive: !!activeNodes && activeNodes.has(source) && activeNodes.has(target),
});
});
renderer.setGraphState({ hasActiveSubgraph: !!activeNodes && activeNodes.size > 0 });
}
function refreshActiveSubgraph() {
if (hoveredNode) {
const neighbors = new Set(graph.neighbors(hoveredNode));
neighbors.add(hoveredNode);
setActiveSubgraph(neighbors);
} else {
setActiveSubgraph(activeSearchNodes);
}
renderer.refresh({ skipIndexation: true });
}
function setSearchQuery(query: string) {
if (searchInput.value !== query) searchInput.value = query;
let selectedNode: string | null = null;
if (query) {
const lcQuery = query.toLowerCase();
const suggestions = graph
.nodes()
.map((n) => ({ id: n, label: graph.getNodeAttribute(n, "label") as string }))
.filter(({ label }) => label.toLowerCase().includes(lcQuery));
if (suggestions.length === 1 && suggestions[0].label === query) {
selectedNode = suggestions[0].id;
activeSearchNodes = null;
} else {
activeSearchNodes = new Set(suggestions.map(({ id }) => id));
}
} else {
activeSearchNodes = null;
}
graph.forEachNode((node) => renderer.setNodeState(node, { isHighlighted: node === selectedNode }));
refreshActiveSubgraph();
if (selectedNode) {
const nodePosition = renderer.getNodeDisplayData(selectedNode) as Coordinates;
renderer.getCamera().animate(nodePosition, { duration: 500 });
}
}
searchInput.addEventListener("input", () => setSearchQuery(searchInput.value || ""));
searchInput.addEventListener("blur", () => setSearchQuery(""));
renderer.on("enterNode", ({ node }) => {
hoveredNode = node;
refreshActiveSubgraph();
});
renderer.on("leaveNode", () => {
hoveredNode = null;
refreshActiveSubgraph();
});

How it works

This example uses sigma’s v4 state management system. No reducers are needed for this pattern.

1. Define custom state types

Giving the Sigma constructor some base custom states for nodes, edges and/or the graph itself, will make it infer the custom state types, which then eases manipulating these states from the styles, event handlers, etc…

Here, we simply extend base Sigma states, by allowing flagging some nodes and edges as “active”, also with a flag on the graph itself:

const renderer = new Sigma(graph, container, {
// ...
customNodeState: {
isActive: false,
},
customEdgeState: {
isActive: false,
},
customGraphState: {
hasActiveSubgraph: false,
},
// ...
});

2. Use conditional style rules

Styles define how elements look based on their state:

const renderer = new Sigma(graph, container, {
styles: {
nodes: [
{ color: { attribute: "color" }, size: { attribute: "size" } },
// Grey out inactive nodes when there's an active subgraph
{
when: (attrs, state, graphState) => graphState.hasActiveSubgraph && !state.isActive && !state.isHighlighted,
then: { color: "#f6f6f6", label: "" },
},
// Render active nodes on top using custom depth layers
{
whenState: "isActive",
then: { depth: "topNodes", labelVisibility: "visible" },
},
],
},
});

3. Update state on interaction

renderer.on("enterNode", ({ node }) => {
const neighbors = new Set(graph.neighbors(node));
neighbors.add(node);
graph.forEachNode((n) => {
renderer.setNodeState(n, { isActive: neighbors.has(n) });
});
renderer.setGraphState({ hasActiveSubgraph: true });
});
renderer.on("leaveNode", () => {
graph.forEachNode((n) => renderer.setNodeState(n, { isActive: false }));
renderer.setGraphState({ hasActiveSubgraph: false });
});

Key concepts

  • Custom state: provide customNodeState / customEdgeState / customGraphState with your own fields
  • Conditional styles: use when predicates to apply styles based on state
  • Depth layers: use primitives.depthLayers to render highlighted elements on top of the rest
  • setNodeState / setGraphState: update state without touching the graph data

Alternative: using reducers

The approach above handles everything through conditional style rules. For more complex logic (such as neighbor lookups, or cross-referencing multiple state fields), you can use reducers as an escape hatch. Reducers receive the full context (key, computed styles, attributes, state, graph state, graph), and return modified display data:

const renderer = new Sigma(graph, container, {
// Extend graph state with a custom field to track the hovered node
customGraphState: {
hoveredNode: null as string | null,
},
// Use that graph state in the reducers for conditional styling
nodeReducer: (key, computed, _attrs, _state, graphState, graph) => {
const { hoveredNode } = graphState;
if (hoveredNode && key !== hoveredNode && !graph.neighbors(hoveredNode).includes(key)) {
return { ...computed, label: "", color: "#f6f6f6" };
}
return computed;
},
edgeReducer: (key, computed, _attrs, _state, graphState, graph) => {
const { hoveredNode } = graphState;
if (hoveredNode) {
const [source, target] = graph.extremities(key);
if (source !== hoveredNode && target !== hoveredNode) {
return { ...computed, hidden: true };
}
}
return computed;
},
});
// Update the custom graph state on hover
renderer.on("enterNode", ({ node }) => renderer.setGraphState({ hoveredNode: node }));
renderer.on("leaveNode", () => renderer.setGraphState({ hoveredNode: null }));

Reducers are more powerful but harder to optimize. Prefer conditional styles when they suffice.

For a deeper understanding of the styles system, see Styles and primitives.