Building Interactive Graph Visualizations with Cytoscape.js

Wahab Cide
How I built an interactive graph theory visualizer using Cytoscape.js and Next.js, featuring algorithmic graph generation, real-time manipulation, and smooth animations—perfect for students studying graph theory concepts.
Introduction
Graph theory is fundamental to computer science, with applications ranging from social network analysis to route optimization and dependency resolution. However, visualizing graphs and understanding their properties can be challenging for students and researchers working with abstract mathematical concepts.
I built an interactive graph theory visualizer to make learning these concepts more intuitive. The tool allows users to create, manipulate, and study various graph structures in real-time—from simple complete graphs to complex structures like the Petersen graph—all through an intuitive web interface.
This article explores the technical implementation: how Cytoscape.js powers the visualization, algorithmic generation of classic graph structures, and handling user interactions while maintaining smooth performance.
Tech Stack
- Framework: Next.js 15 with React 19
- Visualization: Cytoscape.js
- Styling: Tailwind CSS
- Animations: Motion (Framer Motion)
- Language: TypeScript
Why Cytoscape.js?
Several JavaScript graph visualization libraries exist—D3.js, vis.js, sigma.js—each with different strengths. I chose Cytoscape.js for several reasons:
1. Graph-First Design
Unlike D3.js which is a general-purpose visualization library, Cytoscape.js is specifically designed for graph visualization. It provides native support for nodes, edges, and graph algorithms without requiring custom implementations.
2. Built-in Layout Algorithms
Cytoscape ships with multiple layout algorithms (circle, grid, breadthfirst, cose) and supports extensions for advanced layouts. For educational purposes, preset layouts allow precise positioning of mathematically-defined graphs.
3. Interactive by Default
Panning, zooming, node dragging, and selection work out of the box. The library handles touch events, making it mobile-friendly without additional configuration.
4. Performance
Cytoscape uses canvas rendering (with optional WebGL for large graphs), providing smooth performance even with hundreds of nodes. The library efficiently handles graph updates without full re-renders.
Project Setup
Setting up Cytoscape.js in a Next.js 15 project requires handling client-side rendering, as Cytoscape manipulates the DOM directly and cannot be server-rendered.
Installation
npm install cytoscape
npm install @types/cytoscape --save-dev
npm install react-cytoscapejs # Optional React wrapperComponent Structure
The core architecture consists of three main components:
- GraphCanvas: The Cytoscape rendering container with interaction handlers
- Sidebar: UI controls for graph templates and custom input
- Graph Generators: Pure functions that create graph structures algorithmically
Graph Data Structures
Cytoscape.js represents graphs as collections of elements—nodes and edges—each with associated data and optional styling. Here's the TypeScript interface:
export interface GraphElement {
data: {
id: string; // Unique identifier
label?: string; // Display label
source?: string; // For edges: source node ID
target?: string; // For edges: target node ID
};
position?: { // For nodes: x,y coordinates
x: number;
y: number;
};
}
// Example: Triangle graph (3 nodes, 3 edges)
const triangle: GraphElement[] = [
{ data: { id: '1', label: '1' }, position: { x: 0, y: -100 } },
{ data: { id: '2', label: '2' }, position: { x: -87, y: 50 } },
{ data: { id: '3', label: '3' }, position: { x: 87, y: 50 } },
{ data: { id: 'e1-2', source: '1', target: '2' } },
{ data: { id: 'e2-3', source: '2', target: '3' } },
{ data: { id: 'e3-1', source: '3', target: '1' } },
];This structure separates topology (which nodes connect to which) from geometry (where nodes appear). This allows the same graph to be rendered with different layouts.
Algorithmic Graph Generation
Rather than manually creating each graph structure, I implemented generators that algorithmically construct classic graphs. This ensures mathematical correctness and allows parameterization.
Complete Graph Kn
A complete graph connects every pair of nodes. The algorithm positions nodes in a circle for aesthetic symmetry.
function generateCompleteGraph(n: number): GraphElement[] {
const elements: GraphElement[] = [];
const radius = 120;
// Create nodes in circular layout
for (let i = 1; i <= n; i++) {
const angle = (2 * Math.PI * (i - 1)) / n;
elements.push({
data: { id: i.toString(), label: i.toString() },
position: {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle)
}
});
}
// Create all possible edges (n choose 2)
for (let i = 1; i <= n; i++) {
for (let j = i + 1; j <= n; j++) {
elements.push({
data: {
id: `e${i}-${j}`,
source: i.toString(),
target: j.toString()
}
});
}
}
return elements;
}Cycle Graph Cn
A cycle graph forms a closed loop. Each node connects to exactly two neighbors.
function generateCycleGraph(n: number): GraphElement[] {
const elements: GraphElement[] = [];
const radius = 120;
// Create nodes
for (let i = 1; i <= n; i++) {
const angle = (2 * Math.PI * (i - 1)) / n;
elements.push({
data: { id: i.toString(), label: i.toString() },
position: {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle)
}
});
}
// Create cycle edges
for (let i = 1; i <= n; i++) {
const next = i === n ? 1 : i + 1; // Wrap around
elements.push({
data: {
id: `e${i}-${next}`,
source: i.toString(),
target: next.toString()
}
});
}
return elements;
}Bipartite Graph Km,n
A bipartite graph has two disjoint sets of nodes, with edges only between sets (never within).
function generateBipartiteGraph(m: number, n: number): GraphElement[] {
const elements: GraphElement[] = [];
const spacing = 80;
const separation = 200;
// Left set (m nodes)
for (let i = 1; i <= m; i++) {
elements.push({
data: { id: i.toString(), label: i.toString() },
position: {
x: -separation / 2,
y: (i - 1) * spacing - ((m - 1) * spacing) / 2
}
});
}
// Right set (n nodes)
for (let i = 1; i <= n; i++) {
const id = (m + i).toString();
elements.push({
data: { id, label: id },
position: {
x: separation / 2,
y: (i - 1) * spacing - ((n - 1) * spacing) / 2
}
});
}
// All cross-edges (m × n edges total)
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
elements.push({
data: {
id: `e${i}-${m + j}`,
source: i.toString(),
target: (m + j).toString()
}
});
}
}
return elements;
}These generators demonstrate parametric graph construction: the same algorithm produces different graphs based on input parameters, ensuring consistency and mathematical accuracy.
Interactive Features
The visualizer supports real-time graph manipulation, allowing students to experiment with structures and observe properties dynamically.
Initializing Cytoscape
"use client";
import { useEffect, useRef } from 'react';
import cytoscape, { Core } from 'cytoscape';
export default function GraphCanvas({ elements, onGraphChange }) {
const containerRef = useRef<HTMLDivElement>(null);
const cyRef = useRef<Core | null>(null);
useEffect(() => {
if (!containerRef.current || cyRef.current) return;
// Initialize Cytoscape instance
cyRef.current = cytoscape({
container: containerRef.current,
elements: elements,
style: [
{
selector: 'node',
style: {
'background-color': '#ffffff',
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '16px',
'font-weight': 'bold',
'width': '50px',
'height': '50px',
}
},
{
selector: 'edge',
style: {
'width': 3,
'line-color': '#ffffff',
'curve-style': 'straight'
}
},
{
selector: 'node:selected',
style: {
'background-color': '#3b82f6',
'color': '#ffffff'
}
}
],
layout: { name: 'preset' }, // Use provided positions
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: true,
});
return () => {
cyRef.current?.destroy();
};
}, []);
return "w-full h-full" />;
}Adding Nodes on Right-Click
// Right-click empty space to create node
cyRef.current.on('cxttap', (evt) => {
// Only create if clicking background, not existing elements
if (evt.target === cyRef.current) {
const position = evt.position || evt.cyPosition;
const newNode = {
data: {
id: nodeCounter.toString(),
label: nodeCounter.toString()
},
position: position
};
cyRef.current?.add(newNode);
setNodeCounter(prev => prev + 1);
// Notify parent component of graph change
if (onGraphChange) {
onGraphChange(getAllElements());
}
}
});Creating Edges Between Nodes
const createEdge = (sourceId: string, targetId: string) => {
if (!cyRef.current) return;
// Check if edge already exists (prevent duplicates)
const existingEdge = cyRef.current.edges().filter(edge => {
const source = edge.source().id();
const target = edge.target().id();
return (source === sourceId && target === targetId) ||
(source === targetId && target === sourceId);
});
if (existingEdge.length > 0) return;
// Create new edge
const newEdge = {
data: {
id: `e${sourceId}-${targetId}`,
source: sourceId,
target: targetId
}
};
cyRef.current.add(newEdge);
onGraphChange(getAllElements());
};Deleting Selected Elements
const deleteSelected = () => {
if (!cyRef.current) return;
const selected = cyRef.current.$(':selected');
selected.remove(); // Removes nodes and their incident edges
onGraphChange(getAllElements());
};
// Keyboard shortcut for deletion
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
deleteSelected();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);Styling & Animations
Cytoscape.js uses a CSS-like syntax for styling graph elements, supporting selectors, pseudo-classes, and dynamic styling based on data.
Advanced Styling
style: [
{
selector: 'node',
style: {
'background-color': '#ffffff',
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '16px',
'font-weight': 'bold',
'width': '50px',
'height': '50px',
'border-width': '0px'
}
},
{
selector: 'node:selected',
style: {
'background-color': '#3b82f6', // Blue when selected
'color': '#ffffff'
}
},
{
selector: 'edge',
style: {
'width': 3,
'line-color': '#ffffff',
'curve-style': 'straight'
}
},
{
selector: 'edge:selected',
style: {
'line-color': '#3b82f6',
'width': 4
}
},
{
selector: '.temp-edge', // Temporary edge while drawing
style: {
'line-color': '#3b82f6',
'line-style': 'dashed',
'line-dash-pattern': [6, 3]
}
}
]Smooth Transitions
Cytoscape supports animated transitions when graph structure changes:
// Animate node addition
cyRef.current.add(newNode).animate({
style: { 'opacity': 1 },
duration: 300,
easing: 'ease-out'
});
// Animate layout changes
cyRef.current.layout({
name: 'circle',
animate: true,
animationDuration: 500,
animationEasing: 'ease-in-out'
}).run();Performance Optimizations
For educational graphs (typically <100 nodes), performance is excellent out of the box. However, several optimizations improve responsiveness:
1. Batch Updates
// Bad: Multiple renders
elements.forEach(el => cyRef.current.add(el));
// Good: Single batch operation
cyRef.current.batch(() => {
elements.forEach(el => cyRef.current.add(el));
});2. Debounced Change Notifications
import { debounce } from 'lodash';
const notifyChange = debounce(() => {
onGraphChange(getAllElements());
}, 100);
// Use in event handlers
cyRef.current.on('drag', 'node', () => {
notifyChange();
});3. Conditional Rendering
// Only update external state on significant changes
const isInternalChange = useRef(false);
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return; // Skip update from internal change
}
// Update graph from external prop changes
cyRef.current?.elements().remove();
cyRef.current?.add(elements);
}, [elements]);4. Memory Management
useEffect(() => {
// Initialize Cytoscape
cyRef.current = cytoscape({ /* config */ });
// Cleanup on unmount
return () => {
cyRef.current?.destroy();
cyRef.current = null;
};
}, []);Conclusion
Building an interactive graph visualizer with Cytoscape.js demonstrates the power of specialized visualization libraries. By choosing a graph-first tool, I avoided reinventing fundamental concepts like node selection, edge routing, and layout algorithms.
The combination of algorithmic graph generation and interactive manipulation creates an effective learning tool for graph theory. Students can explore classic structures (complete graphs, cycles, bipartite graphs) and experiment with custom configurations in real-time.
Key Takeaways
- ✓ Cytoscape.js provides graph-specific features out of the box
- ✓ Algorithmic generators ensure mathematical correctness
- ✓ TypeScript interfaces improve code quality and developer experience
- ✓ Batch operations and debouncing maintain smooth performance
- ✓ CSS-like styling enables flexible visual customization
- ✓ React integration requires careful state management
The complete source code demonstrates these patterns in a production environment, handling edge cases like duplicate edge prevention, undo/redo history, and responsive mobile layouts.