TL;DR
Cytoscape.js is the full-featured graph library — graph theory algorithms, multiple layouts, styling, analysis, used in bioinformatics and network analysis. vis-network is the interactive network visualization — drag/drop nodes, physics simulation, clustering, part of the vis.js suite. Sigma.js is the WebGL graph renderer — renders large graphs (100K+ nodes) with GPU acceleration, designed for performance. In 2026: Cytoscape.js for graph analysis + visualization, vis-network for interactive network diagrams, Sigma.js for large-scale graph rendering.
Key Takeaways
- Cytoscape.js: ~500K weekly downloads — graph algorithms, layouts, bioinformatics
- vis-network: ~200K weekly downloads — interactive, physics, drag/drop, clustering
- Sigma.js: ~50K weekly downloads — WebGL, large graphs, 100K+ nodes, performance
- Cytoscape.js has the richest algorithm library (shortest path, centrality, etc.)
- vis-network has the best out-of-box interactivity (physics, drag, zoom)
- Sigma.js handles the largest graphs (WebGL rendering)
Cytoscape.js
Cytoscape.js — graph theory + visualization:
Basic graph
import cytoscape from "cytoscape"
const cy = cytoscape({
container: document.getElementById("graph"),
elements: [
// Nodes:
{ data: { id: "react", label: "React" } },
{ data: { id: "next", label: "Next.js" } },
{ data: { id: "remix", label: "Remix" } },
{ data: { id: "vue", label: "Vue" } },
{ data: { id: "nuxt", label: "Nuxt" } },
// Edges:
{ data: { source: "react", target: "next" } },
{ data: { source: "react", target: "remix" } },
{ data: { source: "vue", target: "nuxt" } },
],
style: [
{
selector: "node",
style: {
"background-color": "#3b82f6",
"label": "data(label)",
"color": "#fff",
"text-valign": "center",
"font-size": "12px",
},
},
{
selector: "edge",
style: {
"width": 2,
"line-color": "#64748b",
"target-arrow-color": "#64748b",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
],
layout: { name: "cose" }, // Force-directed layout
})
Graph algorithms
// Shortest path:
const dijkstra = cy.elements().dijkstra({
root: cy.getElementById("react"),
weight: (edge) => edge.data("weight") || 1,
})
const pathToVue = dijkstra.pathTo(cy.getElementById("vue"))
console.log("Distance:", dijkstra.distanceTo(cy.getElementById("vue")))
// Centrality:
const centrality = cy.elements().betweennessCentrality()
cy.nodes().forEach((node) => {
console.log(`${node.id()}: ${centrality.betweenness(node)}`)
})
// Community detection:
const clusters = cy.elements().markovClustering()
clusters.forEach((cluster, i) => {
cluster.style("background-color", colors[i])
})
// BFS/DFS:
cy.elements().bfs({
root: cy.getElementById("react"),
visit(v) { console.log("Visited:", v.id()) },
})
Layouts
// Built-in layouts:
cy.layout({ name: "cose" }).run() // Force-directed
cy.layout({ name: "circle" }).run() // Circular
cy.layout({ name: "grid" }).run() // Grid
cy.layout({ name: "breadthfirst" }).run() // Hierarchical
cy.layout({ name: "concentric" }).run() // Concentric circles
cy.layout({ name: "random" }).run() // Random
// Force-directed with options:
cy.layout({
name: "cose",
idealEdgeLength: 100,
nodeOverlap: 20,
gravity: 80,
numIter: 1000,
animate: true,
}).run()
vis-network
vis-network — interactive networks:
Basic network
import { Network, DataSet } from "vis-network/standalone"
const nodes = new DataSet([
{ id: 1, label: "React", color: "#61dafb" },
{ id: 2, label: "Next.js", color: "#000000" },
{ id: 3, label: "Remix", color: "#121212" },
{ id: 4, label: "Vue", color: "#42b883" },
{ id: 5, label: "Nuxt", color: "#00dc82" },
])
const edges = new DataSet([
{ from: 1, to: 2, label: "powers" },
{ from: 1, to: 3, label: "powers" },
{ from: 4, to: 5, label: "powers" },
])
const container = document.getElementById("network")!
const network = new Network(container, { nodes, edges }, {
physics: {
forceAtlas2Based: {
gravitationalConstant: -50,
centralGravity: 0.01,
springLength: 200,
},
solver: "forceAtlas2Based",
},
nodes: {
shape: "dot",
size: 25,
font: { size: 14, color: "#ffffff" },
borderWidth: 2,
},
edges: {
width: 2,
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
font: { size: 10, align: "middle" },
},
})
Dynamic updates
// Add nodes/edges dynamically:
nodes.add({ id: 6, label: "Svelte", color: "#ff3e00" })
edges.add({ from: 6, to: 7, label: "powers" })
// Remove:
nodes.remove(6)
// Update:
nodes.update({ id: 1, label: "React 19", color: "#00d8ff" })
// Physics — stabilize then freeze:
network.once("stabilized", () => {
network.setOptions({ physics: false })
})
Events
// Click events:
network.on("click", (params) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0]
console.log("Clicked node:", nodes.get(nodeId))
}
})
// Hover:
network.on("hoverNode", (params) => {
console.log("Hovering:", params.node)
})
// Drag:
network.on("dragEnd", (params) => {
if (params.nodes.length > 0) {
const pos = network.getPositions(params.nodes)
console.log("New positions:", pos)
}
})
// Double-click to edit:
network.on("doubleClick", (params) => {
if (params.nodes.length > 0) {
const node = nodes.get(params.nodes[0])
const newLabel = prompt("New label:", node.label)
if (newLabel) nodes.update({ id: node.id, label: newLabel })
}
})
Clustering
// Cluster by property:
network.cluster({
joinCondition: (nodeOptions) => nodeOptions.group === "frontend",
clusterNodeProperties: {
label: "Frontend",
shape: "diamond",
color: "#3b82f6",
},
})
// Cluster by connection:
network.clusterByConnection(1) // Cluster around node 1
// Open cluster on click:
network.on("selectNode", (params) => {
if (network.isCluster(params.nodes[0])) {
network.openCluster(params.nodes[0])
}
})
Sigma.js
Sigma.js — WebGL graph renderer:
Basic graph
import Sigma from "sigma"
import Graph from "graphology"
const graph = new Graph()
// Add nodes:
graph.addNode("react", {
label: "React",
x: 0, y: 0,
size: 20,
color: "#61dafb",
})
graph.addNode("next", {
label: "Next.js",
x: 1, y: 1,
size: 15,
color: "#000000",
})
graph.addNode("vue", {
label: "Vue",
x: -1, y: 1,
size: 15,
color: "#42b883",
})
// Add edges:
graph.addEdge("react", "next", { size: 2, color: "#64748b" })
graph.addEdge("react", "vue", { size: 1, color: "#64748b" })
// Render:
const renderer = new Sigma(graph, document.getElementById("graph")!, {
renderEdgeLabels: true,
})
Large graph (100K+ nodes)
import Sigma from "sigma"
import Graph from "graphology"
import forceAtlas2 from "graphology-layout-forceatlas2"
const graph = new Graph()
// Generate large graph:
for (let i = 0; i < 100000; i++) {
graph.addNode(i, {
label: `Node ${i}`,
x: Math.random() * 1000,
y: Math.random() * 1000,
size: 2 + Math.random() * 5,
color: `hsl(${Math.random() * 360}, 70%, 50%)`,
})
}
// Add random edges:
for (let i = 0; i < 200000; i++) {
const source = Math.floor(Math.random() * 100000)
const target = Math.floor(Math.random() * 100000)
if (source !== target && !graph.hasEdge(source, target)) {
graph.addEdge(source, target, { size: 1 })
}
}
// Apply force layout:
forceAtlas2.assign(graph, { iterations: 100 })
// Render — WebGL handles 100K+ nodes at 60fps:
const renderer = new Sigma(graph, container)
Search and highlight
import Sigma from "sigma"
const renderer = new Sigma(graph, container)
// Search nodes:
function searchNode(query: string) {
const matching = graph.filterNodes((_, attrs) =>
attrs.label.toLowerCase().includes(query.toLowerCase())
)
// Highlight matching:
renderer.setSetting("nodeReducer", (node, data) => {
if (matching.length > 0 && !matching.includes(node)) {
return { ...data, color: "#333", label: "" }
}
return data
})
renderer.refresh()
}
Events
const renderer = new Sigma(graph, container)
// Click:
renderer.on("clickNode", ({ node }) => {
console.log("Clicked:", graph.getNodeAttributes(node))
})
// Hover:
renderer.on("enterNode", ({ node }) => {
// Highlight neighbors:
const neighbors = new Set(graph.neighbors(node))
renderer.setSetting("nodeReducer", (n, data) => {
if (n === node || neighbors.has(n)) return data
return { ...data, color: "#333", label: "" }
})
renderer.refresh()
})
renderer.on("leaveNode", () => {
renderer.setSetting("nodeReducer", (_, data) => data)
renderer.refresh()
})
Feature Comparison
| Feature | Cytoscape.js | vis-network | Sigma.js |
|---|---|---|---|
| Renderer | Canvas | Canvas | WebGL |
| Max nodes (smooth) | ~5,000 | ~5,000 | 100,000+ |
| Graph algorithms | ✅ (many) | ❌ | Via graphology |
| Force layout | ✅ | ✅ (physics) | Via graphology |
| Built-in drag/drop | ✅ | ✅ | ✅ |
| Clustering | ❌ | ✅ | Via graphology |
| Edge labels | ✅ | ✅ | ✅ |
| Node shapes | ✅ (many) | ✅ (many) | Circle/custom |
| Animations | ✅ | ✅ (physics) | ❌ |
| React bindings | Via ref | Via ref | @react-sigma |
| TypeScript | ✅ | ✅ | ✅ |
| Used by | Bioinformatics | Dashboards | Large networks |
| Weekly downloads | ~500K | ~200K | ~50K |
When to Use Each
Use Cytoscape.js if:
- Need graph algorithms (shortest path, centrality, BFS/DFS)
- Building bioinformatics or scientific network analysis
- Need multiple layout algorithms
- Want the richest styling and customization
Use vis-network if:
- Building interactive network diagrams
- Need physics-based layout simulation
- Want drag-and-drop node editing
- Need clustering and grouping features
Use Sigma.js if:
- Rendering large graphs (10K–100K+ nodes)
- Need WebGL performance for complex networks
- Using graphology for graph algorithms
- Building social network or knowledge graph visualizations
TypeScript Type Safety and Graph Data Modeling
All three libraries offer TypeScript support, but the quality of type inference differs significantly. Cytoscape.js provides types through @types/cytoscape and uses a flexible but loosely-typed element data model — ele.data() returns any, so you need to cast or validate the shape yourself. The typical pattern is to define your own node and edge data interfaces and cast at read time: const nodeData = node.data() as PackageNodeData. This works but requires discipline, since the compiler doesn't enforce that data added to the graph matches your interfaces.
Sigma.js with graphology provides stronger typing through graphology's generic type parameters. new Graph<NodeAttributes, EdgeAttributes, GraphAttributes>() locks the graph to specific attribute shapes, and graph.getNodeAttributes(node) returns your typed attributes rather than any. The graphology-types package defines the base interfaces, and the broader graphology ecosystem (layout packages, metrics, generators) all respect these generics. This makes Sigma.js with graphology the most type-safe option for teams that need compile-time guarantees about what data lives on their graph elements.
vis-network sits in the middle — it ships its own TypeScript types and the DataSet<T> generic enforces the shape of node and edge objects you add. nodes.add({ id: 1, label: "React", group: 3 }) is type-checked against your defined node type. However, vis-network's physics configuration objects are complex and the types can be incomplete for some advanced physics solver options, occasionally requiring @ts-ignore annotations in heavily customized configurations.
Choosing a Layout Algorithm for Your Graph Structure
All three libraries are DOM-based and require a container element, which creates a seam with React's virtual DOM that each library handles differently. The baseline pattern for all three is using a ref to get the container element and initializing the library instance inside useEffect with a cleanup function that destroys the instance on unmount.
Cytoscape.js with React works well with this pattern because Cytoscape manages its own internal state — you hand it the container element and let it own that DOM subtree. The challenge is data updates: when your graph data changes in React state, you need to call cy.elements().remove() followed by cy.add(newElements), or diff the changes yourself with cy.getElementById() to find added/removed nodes. The react-cytoscapejs library wraps this pattern with a <CytoscapeComponent> that accepts elements and stylesheet as React props and handles incremental updates, but it's a thin wrapper that still requires understanding Cytoscape's core API.
Sigma.js has official React bindings in the @react-sigma package, which provides <SigmaContainer>, <LoadGraph>, and useLoadGraph hooks. The graphology graph object is the data layer — you update it with graph.addNode(), graph.addEdge(), graph.mergeNodeAttributes() and Sigma re-renders automatically because it listens to graphology's event system. This event-driven reactivity is more natural in React than Cytoscape's imperative update API.
vis-network's DataSet objects are the reactive data layer: nodes.add(), nodes.update(), edges.remove() trigger immediate visual updates without reinitializing the network. This makes React integration straightforward — store the DataSet instances in a ref, update them when props change in a useEffect, and vis-network reacts automatically.
Layout algorithm selection has a larger impact on graph readability than any styling choice. Force-directed layouts (Cytoscape's COSE, vis-network's ForceAtlas2Based, graphology's ForceAtlas2) work by simulating spring forces between connected nodes, pushing unconnected nodes apart and pulling connected ones together. They produce natural-looking graph embeddings where clusters of highly-connected nodes group visually, but they are computationally expensive and non-deterministic — the same graph can produce different layouts on different runs. For graphs with clear hierarchical structure (file system trees, organizational charts, dependency trees), breadth-first or Dagre layouts produce cleaner results at lower computational cost.
The choice of layout algorithm should be driven by graph structure. Biological networks and social graphs benefit from force-directed layouts that reveal community structure. Knowledge graphs and concept maps often do well with force-directed as well. Package dependency graphs — where a few popular packages have many dependents — often look better with hierarchical layouts where the depth corresponds to dependency distance from the root. Cytoscape.js's built-in breadthfirst and concentric layouts, or the dagre extension, are the right choices here.
Graphology: The Data Layer Behind Sigma.js
Sigma.js doesn't manage its own graph data structure — it renders a graphology Graph object. Graphology is a separate library (npm install graphology) with its own extensive ecosystem: graphology-layout-forceatlas2 for force-directed layouts, graphology-communities-louvain for community detection, graphology-shortest-path for path finding, graphology-generators for creating test graphs, and graphology-metrics for centrality calculations. This separation of concerns is intentional — Sigma is purely a renderer, and graphology is purely a graph data structure with algorithms.
The graphology ecosystem covers most of what Cytoscape.js builds in: shortest path (Dijkstra, A*), centrality (betweenness, degree, eigenvector), community detection (Louvain, Label Propagation), and force-directed layout (ForceAtlas2, FA2 with Barnes-Hut optimization). The difference is that these are separate npm packages rather than built-in methods. For large graphs where you're running ForceAtlas2 — graphology's primary layout algorithm — the graphology-layout-forceatlas2 package includes a WebWorker mode (FA2Layout) that runs the physics simulation on a background thread, keeping the main thread free for rendering. This is a significant advantage over Cytoscape.js's COSE layout, which runs synchronously on the main thread and blocks UI interaction during layout calculation on graphs above a few thousand nodes.
Performance Boundaries and When to Switch Libraries
The performance limit of Canvas-based rendering (Cytoscape.js and vis-network) is approximately 3,000-5,000 nodes before frame rate degrades noticeably. At this scale, layout calculations, event hit-testing, and redrawing all compete for CPU time on the main thread. Both libraries offer partial mitigations: Cytoscape.js has a motionBlur option that reduces rendering fidelity during animation, and vis-network's physics.solver options include Barnes-Hut approximation which reduces force calculation complexity from O(n²) to O(n log n) for large graphs.
Sigma.js with WebGL has a practical performance ceiling around 100,000-500,000 nodes, depending on edge density and hardware. The WebGL renderer batches all node and edge draws into GPU draw calls, and Sigma's custom shaders are optimized for the graph rendering use case. The trade-off is that WebGL rendering is less flexible for custom node shapes — Sigma natively renders circles and edges, and custom node renderers require writing GLSL shader code or using a WebGL canvas overlay for complex shapes.
For graphs between 5,000 and 50,000 nodes, both Sigma.js and a performance-tuned vis-network can work, but Sigma's WebGL rendering will produce smoother interactions. Above 50,000 nodes, Sigma.js is the only practical option among these three without server-side pre-rendering or level-of-detail sampling.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on Cytoscape.js v3.x, vis-network v9.x, and Sigma.js v3.x.
Compare graph visualization and data libraries on PkgPulse →
See also: AVA vs Jest and Mermaid vs D3.js vs Chart.js 2026, Fabric.js vs Konva vs PixiJS: Canvas & 2D Graphics 2026.