Skip to content
sigma.js sigma.js
v4.0.6 Docs Examples GitHub 12.2K

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.

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();
});

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

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,
},
// ...
});

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" },
},
],
},
});
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 });
});
  • 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

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, visibility: "hidden" };
}
}
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.