visualize-graph-cytoscape
Load this skill when building an interactive node-and-edge graph UI with Cytoscape.js: a network, dependency map, knowledge graph, or org chart. Trigger on "visualize this graph", "draw a network", or
SKILL.mdplugins/standalone/ceh-web-frontend/skills/visualize-graph-cytoscape/SKILL.md
GitHubVisualize a Graph with Cytoscape.js
Cytoscape.js is a canvas-rendering graph theory library. It is a building block, not an app: it gives you a graph model, a stylesheet, layouts, algorithms, and an event system. Everything else — panels, search boxes, legends, detail views — is your own code.
The examples pin Cytoscape.js 3.34.2.
Done when the graph renders with readable labels and passes references/checklist.md.
Procedure
Follow these steps in order. Do not skip step 1: picking the wrong layout is the difference between a legible diagram and a hairball.
0. Rule zero: the ten failures that break most builds
Most broken Cytoscape.js builds fail for one of ten reasons. Check every one of these before you consider a visualizer finished. They are listed in the order they usually bite.
The container must have a non-zero height before
cytoscape()is called. A bare<div id="cy">is 0px tall and the graph renders invisibly, or every node stacks at one point. Set an explicitheight(px, vh, or a flexed parent with a definite size) in CSS that is parsed before init. The official docs are explicit: put stylesheets in<head>before any Cytoscape-related code, or dimensions get reported incorrectly.Arrows do not render on the default edge style.
curve-styledefaults tohaystack, and haystack edges do not support endpoint arrows, loops, or compound nodes. If your graph is directed, you must set'curve-style': 'bezier'(orstraight,taxi,segments) in the edge selector. This is the single most common "why are my arrows missing" bug.The stylesheet is not CSS. It is a JSON array of
{ selector, style }blocks using Cytoscape's own property names (background-color,line-color,label). CSS variables,rgb(var(--x)),classattributes from your HTML, Tailwind classes, and!importantdo nothing. Resolve theme colors to concrete strings in JS before passing them in. Seereferences/theming.md.Every element needs a unique string
id, and every edge'ssource/targetmust name a node that exists. A dangling edge throws on init. Validate the data first — there is a ready-made guard inreferences/integration.md.cyis an imperative instance, never React state. Hold it in a ref, create it once, and callcy.destroy()in cleanup. Putting it inuseStatecauses infinite re-render loops and leaked canvases.Call
cy.resize()when the container changes size for any reason other than a window resize. Cytoscape hookswindow.resizeonly; it cannot observe arbitrary DOM elements. A sidebar opening, a tab switching, or a flex reflow will leave the canvas mis-sized and taps offset from where you clicked. Use aResizeObserver.Layout is asynchronous for force-directed algorithms.
cy.layout(...).run()returns immediately. If you need to act on final positions (fit, screenshot, measure), listen forlayoutstopor awaitlayout.promiseOn('layoutstop'). A layout passed as thelayout:constructor option cannot be listened to this way. It runs duringcytoscape(), before your code has an instance to bind a handler to, so acy.on('layoutstop')orcy.one('layoutstop')registered on the next line never fires and every check you hung off it silently does not run. Two ways out, and you must pick one deliberately: keeplayout:in the constructor and usecy.ready(fn), which fires after that initial layout; or omitlayout:, thenconst l = cy.layout(opts); l.one('layoutstop', fn); l.run();. Use the second whenever the handler must also run on later re-layouts.layoutstopalso lands well afteranimationDurationon an animated layout — a 300ms animation on 150 nodes took over 1.6s — so never verify it with a short fixed sleep and conclude it never fires.The container div must be empty and owned by Cytoscape. Do not render your own children into it. If taps land at the wrong offset, this or a missing
cy.resize()is why.cy.fit()on a large graph can leave every label unreadable. Whenzoom × font-sizefalls undermin-zoomed-font-size, Cytoscape draws no labels at all. Gate on that pixel size, never a zoom constant. See "Sizing and readable defaults" below.On live or polling data, never
remove()thenadd(). It silently resets the user's zoom, pan, and selection on every refresh. Diff into the existing instance withsyncGraph(references/integration.md) and re-run the layout only when the topology actually changed.
1. Classify the graph, then pick the layout
| Graph shape | Layout | Notes |
|---|---|---|
| Tree / forest / clear roots | breadthfirst |
Built in. Set roots and direction. |
| DAG, pipeline, flowchart | dagre (extension) |
Proper layered ranking. Best for left→right flows. |
| DAG needing orthogonal routing | elk (extension), layered algorithm |
Heavier, best-in-class edge routing. |
| General network, no hierarchy | fcose (extension) |
Use this, not cose. Faster and much better quality. |
| Network with compound/grouped nodes | fcose or cola |
Both handle compound parents well. |
| Clustered / community structure | cise (extension) |
Places clusters in circles. |
| Small graph, categorical ordering | circle, concentric, grid |
Deterministic, instant, no jitter. |
| Positions supplied by your data | preset |
Reads position: {x, y} from each node. |
Built-in layouts only: null, random, preset, grid, circle, concentric,
breadthfirst, cose. Everything else is an extension you must load and register.
Lifecycle, re-running, and the extension layouts are in references/layouts.md. Full option
lists are in the Cytoscape.js docs.
Rule of thumb: if the user's graph is hierarchical, do not use a force layout. Force layouts on trees look like accidents.
2. Shape the data
Real inputs are almost never already in elements JSON. They arrive in one of four shapes, and converting them is where dangling edges and duplicate ids get introduced. Use the bundled helper rather than writing the conversion by hand each time:
// scripts/to-elements.js — works in Node and the browser
const {
fromParentPointer,
fromDependsOn,
fromEdgeList,
fromAdjacency,
sanitize,
} = require("./scripts/to-elements.js");
| Input shape | Example | Use |
|---|---|---|
| Parent pointer (org charts, file trees, category trees) | {id, name, manager_email} |
fromParentPointer(rows, {id:'email', parent:'manager_email', label:'name'}) |
| Dependency arrays (Terraform, package manifests, imports) | {id, depends_on:[...]} |
fromDependsOn(rows, {id:'id', deps:'depends_on'}) |
| Edge list plus optional node list | [{from, to, weight}] |
fromEdgeList(edges, nodes, {source:'from', target:'to'}) |
| Adjacency matrix | [[0,1],[1,0]] + labels |
fromAdjacency(matrix, labels) |
Every one of these routes through sanitize(), which drops duplicate ids and edges
pointing at nodes that do not exist, and returns the dropped ones as warnings.
Surface those warnings in the UI. In real exports, edges pointing outside the dataset
are common — a manager_email for someone who left, a depends_on on a module in
another state file. Silently dropping them makes the graph quietly wrong; showing "3
relationships referenced records not in this file" makes it honest.
The resulting shape:
const elements = [
{ data: { id: "a", label: "Auth service", type: "service" } },
{ data: { id: "b", label: "Postgres", type: "datastore" } },
{ data: { id: "a->b", source: "a", target: "b", kind: "reads" } },
];
Put everything you will style or query into data. Style with data(...) and
mapData(...) mappers or with selectors on data fields — not with per-element style
overrides, which bypass the stylesheet and are hard to undo.
Use classes for state that changes at runtime (highlighted, dimmed, faded) and
data for facts about the entity. This split keeps interaction code to
addClass / removeClass.
Ids from real data often contain characters the #id selector grammar cannot carry:
@, $, /, : and spaces all fail, and they fail silently — cy.$('#ceo@corp.com')
returns an empty collection rather than throwing, so the bug surfaces as a button that
does nothing. (. and - do parse: cy.$('#aws_vpc.main') matches by id even when a
class named main exists. Do not rely on it — the next id in the same dataset will have
an @ in it.) Use cy.$id('aws_vpc.main'), which takes the id as a plain string with no
parsing and no escaping, or pass collections directly to options like roots — never
hand-escape into a selector string.
3. Write the stylesheet
Start from the base stylesheet in assets/template.html, then adjust. Every visualizer
needs at minimum: node body, node label, edge line, edge arrow, selected state, and the
dim/highlight pair used by interactions. Look up any other property in the Cytoscape.js style
docs.
4. Wire the interactions
Tap-to-highlight-neighborhood is the baseline expectation for any explorer. Search,
filter, expand/collapse, and detail panels build on the same collection primitives.
Copy-paste-ready implementations are in references/recipes.md.
5. Verify
Run through references/checklist.md before declaring done. It is short and catches the
failures that only appear at runtime.
Minimal working skeleton
This runs as-is. Use it as the starting point rather than writing init from memory.
<style>
#cy {
width: 100%;
height: 600px;
display: block;
}
</style>
<div id="cy"></div>
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.34.2/dist/cytoscape.min.js"></script>
<script>
const cy = cytoscape({
container: document.getElementById("cy"),
elements: [
{ data: { id: "a", label: "A" } },
{ data: { id: "b", label: "B" } },
{ data: { id: "ab", source: "a", target: "b" } },
],
style: [
{
selector: "node",
style: {
"background-color": "#7F77DD",
label: "data(label)",
"font-size": 11,
color: "#26215C",
"text-valign": "bottom",
"text-margin-y": 4,
width: 32,
height: 32,
},
},
{
selector: "edge",
style: {
"curve-style": "bezier",
width: 1.5,
"line-color": "#B4B2A9",
"target-arrow-shape": "triangle",
"target-arrow-color": "#B4B2A9",
"arrow-scale": 0.9,
},
},
{ selector: ".dimmed", style: { opacity: 0.15 } },
{
selector: ".highlighted",
style: { "border-width": 3, "border-color": "#D85A30" },
},
],
layout: {
name: "breadthfirst",
directed: true,
padding: 30,
spacingFactor: 1.2,
},
minZoom: 7 / 11, // min-zoomed-font-size / font-size — below this, no labels
maxZoom: 3,
wheelSensitivity: 0.3,
});
cy.on("tap", "node", (evt) => {
const hood = evt.target.closedNeighborhood();
cy.elements().addClass("dimmed").removeClass("highlighted");
hood.removeClass("dimmed").addClass("highlighted");
});
cy.on("tap", (evt) => {
if (evt.target === cy) cy.elements().removeClass("dimmed highlighted");
});
</script>
Loading the library
- npm:
npm install cytoscape, thenimport cytoscape from 'cytoscape'.import * as cytoscape from 'cytoscape'does not work — the default export is required. - CDN: cdnjs, jsDelivr, or unpkg. Pin the version.
dist/cytoscape.min.jsis UMD and exposes acytoscapeglobal;dist/cytoscape.esm.min.mjsis the ES module build. - Extensions: register once, before creating any instance:
import cytoscape from "cytoscape"; import fcose from "cytoscape-fcose"; cytoscape.use(fcose);
Extensions from a CDN
Guessing the UMD global name is the usual failure here, and a wrong guess fails silently
until layout time. You do not have to guess: the global is the package name in
camelCase — cytoscape-fcose exposes cytoscapeFcose, cytoscape-cose-bilkent
exposes cytoscapeCoseBilkent. That rule covers every extension, including ones not
listed below. What you cannot derive is the load order, which is what the table carries.
Pin the core exactly. Pin extensions to a major range: their patch releases are not tracked here, and a stale exact pin is worse than a range.
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.34.2/dist/cytoscape.min.js"></script>
<!-- fcose: needs layout-base then cose-base first -->
<script src="https://cdn.jsdelivr.net/npm/layout-base@2/layout-base.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cose-base@2/cose-base.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cytoscape-fcose@2/cytoscape-fcose.js"></script>
<!-- dagre: needs dagre first -->
<script src="https://cdn.jsdelivr.net/npm/dagre@0.8/dist/dagre.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2/cytoscape-dagre.js"></script>
<script>
// Never call cytoscape.use(window.X) unguarded: if the script tag failed, X is
// undefined and the page dies at layout time with nothing on screen. Degrade instead.
let layoutName = "cose"; // built-in, always available
if (window.cytoscapeFcose) {
cytoscape.use(window.cytoscapeFcose);
layoutName = "fcose";
} else {
setStatus("fcose did not load — using the built-in cose layout.");
}
</script>
| Extension | UMD global | Must load first |
|---|---|---|
cytoscape-fcose |
cytoscapeFcose |
layout-base, cose-base |
cytoscape-cose-bilkent |
cytoscapeCoseBilkent |
layout-base, cose-base |
cytoscape-dagre |
cytoscapeDagre |
dagre |
cytoscape-klay |
cytoscapeKlay |
klayjs |
cytoscape-cola |
cytoscapeCola |
webcola |
cytoscape-popper |
cytoscapePopper |
@popperjs/core |
cytoscape-edgehandles |
cytoscapeEdgehandles |
— |
cytoscape-cxtmenu |
cytoscapeCxtmenu |
— |
Guard every extension this way, not just the layout ones — edgehandles and cxtmenu
fail the same silent way. Fall back to a built-in layout rather than shipping a page
that throws: breadthfirst for hierarchies and cose for networks are always
available with zero extra script tags, and a working graph with a slightly worse layout
beats a blank canvas.
Published npm versions are immutable, so a pinned jsDelivr URL keeps resolving — the pin goes stale, it does not break. Do not spend effort verifying the URLs; spend it on the guard above, which is what actually catches a script tag that did not load.
Rules
Sizing and readable defaults
The most common way a technically-correct graph fails its user is being rendered at a zoom nobody can read. Work out the zoom before deciding to fit.
A bare zoom threshold is the wrong test. What decides legibility is the label's
rendered size — zoom × font-size, in CSS pixels — and a gate written as a constant like
fitZoom >= 0.35 passes graphs whose labels Cytoscape then refuses to draw at all.
Measured on a 116-node org chart, font-size: 10, min-zoomed-font-size: 7, viewport
1828×559:
| Layout | Bounding box | Aspect | Fit zoom | Label at that zoom | Result |
|---|---|---|---|---|---|
breadthfirst downward, spacingFactor: 1.15 |
4268 × 393 | 10.9 : 1 | 0.47 | 4.6px | labels hidden — grey confetti |
breadthfirst circle: true, spacingFactor: 1.0 |
1142 × 1115 | 1.02 : 1 | 0.50 | 5.0px | labels hidden; structure readable |
Both clear 0.35 comfortably and both are unusable. circle: true fixes the aspect ratio —
the hierarchy is legible as shape — but it does not fix label size, so it is a framing
fix, not a readability fix. Trees also get wider with depth, so a tree that fits
comfortably at 30 nodes will not at 150, and the failure never shows up in a small test
fixture.
Gate on the pixel size instead:
const MIN_LABEL_PX = 7; // keep equal to your min-zoomed-font-size
const FONT_PX = 10; // the node font-size in your stylesheet
// Constructor layout? use cy.ready(...) instead — see rule 7.
const layout = cy.layout(opts);
layout.one("layoutstop", () => {
const bb = cy.elements().boundingBox();
const fitZoom = Math.min(cy.width() / bb.w, cy.height() / bb.h);
if (fitZoom * FONT_PX >= MIN_LABEL_PX) {
cy.fit(30); // labels survive the fit
} else {
cy.zoom(MIN_LABEL_PX / FONT_PX); // smallest zoom that still renders labels
cy.center(cy.nodes().roots()); // start somewhere meaningful
setStatus("Large graph — drag to pan, or search to jump to a node.");
}
});
layout.run();
With those numbers the real gate is fitZoom >= 0.7, twice what a 0.35 constant allows.
Raising the font size lowers the gate proportionally; it does not remove it.
When fitting is not viable, give the user a way in: search-and-zoom-to-node, collapse to
the top two levels with expand on demand, or circle: true plus a starting zoom for
trees. Any of these is better than a fitted view of unreadable specks. Set minZoom to
MIN_LABEL_PX / FONT_PX too, so users cannot hand-zoom into that state either.
Reference files (${CLAUDE_SKILL_DIR})
Load these as needed; do not read all of them upfront.
references/layouts.md— layout lifecycle, re-running without losing the user's place, which extension to pickreferences/integration.md— React, Vue, Svelte, bundlers, SSR, data validationreferences/theming.md— dark mode, design tokens, resolving CSS variables to canvasreferences/recipes.md— highlight, search, filter, expand/collapse, tooltips, export, context menusreferences/performance.md— scaling to thousands of elements, and when to stopreferences/checklist.md— pre-delivery verificationassets/template.html— a complete, working, themeable single-file visualizerscripts/to-elements.js— data-shape converters and thesanitize()guard; require it rather than rewriting the conversion, and surface itswarnings
Stop conditions
Cytoscape.js is the wrong tool in the cases below. Say so rather than forcing it.
- Above ~5,000 elements interaction gets sluggish even tuned; above ~10,000 it is the wrong library. Reach for Sigma.js, regl-based renderers, or server-side layout with a rendered tile/image.
- Purely hierarchical data with no cross-links — a tree component or a D3 tidy tree is simpler and looks better.
- Statistical relationships (correlation, flows by volume) — a heatmap, chord diagram, or Sankey communicates better than a node-link diagram.
- A single fixed diagram that never changes — hand-authored SVG or Mermaid is less machinery.