NVL (Neo4j Visualization Library)
When to Use
Use this skill when building graph visualizations with NVL. Covers rendering setup, styling nodes and relationships, layout algorithms, user interaction handling, and proven patterns from production FinSight usage.
- Installation & Packages
npm install @neo4j-nvl/base @neo4j-nvl/react @neo4j-nvl/interaction-handlers
Package Purpose
@neo4j-nvl/base
Core NVL class, types (Node , Relationship , NvlOptions )
@neo4j-nvl/react
BasicNvlWrapper , InteractiveNvlWrapper , StaticPictureWrapper
@neo4j-nvl/interaction-handlers
ZoomInteraction , PanInteraction , ClickInteraction , etc.
- React Wrappers
All wrappers accept a ref for imperative access to the underlying NVL instance.
import type NVL from "@neo4j-nvl/base"; const nvlRef = useRef<NVL>(null); // Then: nvlRef.current.setZoom(1.5)
BasicNvlWrapper
Minimal wrapper — renders the graph, syncs prop changes, no built-in interaction handlers.
import { BasicNvlWrapper } from "@neo4j-nvl/react";
<BasicNvlWrapper ref={nvlRef} nodes={nodes} rels={relationships} layout="forceDirected" layoutOptions={{ enableCytoscape: true }} nvlOptions={{ initialZoom: 1 }} nvlCallbacks={{ onLayoutDone: () => console.log("done") }} zoom={0.8} pan={{ x: 0, y: 0 }} positions={nodePositions} // Apply positions via setNodePositions onInitializationError={(err) => {}} onClick={(event) => {}} // Native DOM events passed as props />;
InteractiveNvlWrapper (primary choice)
Full mouse event support — click, drag, hover, zoom, pan, box select, lasso.
import { InteractiveNvlWrapper } from "@neo4j-nvl/react";
<InteractiveNvlWrapper ref={nvlRef} nodes={nodes} rels={relationships} layout="forceDirected" layoutOptions={layoutOptions} nvlOptions={nvlOptions} nvlCallbacks={callbacks} zoom={0.8} pan={{ x: 0, y: 0 }} mouseEventCallbacks={{ onNodeClick: (node, hitTargets, evt) => {}, onNodeDoubleClick: (node, hitTargets, evt) => {}, onNodeRightClick: (node, hitTargets, evt) => {}, onRelationshipClick: (rel, hitTargets, evt) => {}, onRelationshipDoubleClick: (rel, hitTargets, evt) => {}, onRelationshipRightClick: (rel, hitTargets, evt) => {}, onCanvasClick: (evt) => {}, onCanvasDoubleClick: (evt) => {}, onCanvasRightClick: (evt) => {}, onHover: (element, hitTargets, evt) => {}, onDrag: (nodes) => {}, onPan: (evt) => {}, onZoom: (zoomLevel) => {}, onBoxSelect: ({ nodes, rels }) => {}, onLassoSelect: ({ nodes, rels }) => {}, }} interactionOptions={{}} // Toggle interaction behaviors onInitializationError={(err) => {}} />;
StaticPictureWrapper
Non-interactive render — for thumbnails, exports, or read-only views. Same props as BasicNvlWrapper minus interaction-related ones.
- Node Interface
interface Node { // Required id: string; // Unique across all nodes AND relationships
// Visual color?: string; // Background color size?: number; // Node dimensions icon?: string; // URL or data URI (must be square, must be black) overlayIcon?: { url: string; position?: number[]; size?: number };
// Captions caption?: string; // Simple text label captions?: StyledCaption[]; // Multiple styled captions (overrides caption) captionSize?: number; captionAlign?: "center" | "top" | "bottom";
// State selected?: boolean; // Blue border highlight hovered?: boolean; // Hover visual state activated?: boolean; // Activation state disabled?: boolean; // Grayed out pinned?: boolean; // Immune to layout forces
// Experimental html?: HTMLElement; // DOM element rendered on top of node }
type StyledCaption = { key?: string; value?: string; styles?: string[]; // e.g. ['bold', 'italic'] };
FinSight node mapping pattern
// Label-based style config → NVL node const nvlNodes: NVLNode[] = visibleNodes.map((node) => { const primaryLabel = getPrimaryLabel(node); // node.labels[0] const styleConfig = getNodeStyleConfig(primaryLabel); // { baseColor, baseSize, icon } const displayName = getNodeDisplayName(node); // Label-aware formatting const iconDataUri = getCachedIconDataUri(iconName, "#000000"); // Black SVG data URI
return { id: node.id, caption: displayName, captionAlign: "top", size: styleConfig.baseSize, color: styleConfig.baseColor, icon: iconDataUri, disabled: hiddenNodeIds.has(node.id), selected: selectedNodeIds.has(node.id), hovered: highlightedNodeIds.has(node.id), }; });
- Relationship Interface
interface Relationship { // Required id: string; // Unique across all nodes AND relationships from: string; // Source node ID to: string; // Target node ID
// Visual color?: string; width?: number; type?: string; // Relationship type label
// Captions caption?: string; captions?: StyledCaption[]; // Overrides caption when set captionSize?: number; captionAlign?: "center" | "top" | "bottom"; captionHtml?: HTMLElement; // Experimental
// State selected?: boolean; hovered?: boolean; disabled?: boolean;
// Overlay overlayIcon?: { url: string; position?: number[]; size?: number }; }
FinSight relationship mapping pattern
const nvlRelationships: NVLRelationship[] = visibleRelationships.map((rel) => { const styleConfig = getRelationshipStyleConfig(rel.type); // { color, width } return { id: rel.id, from: rel.source, to: rel.target, caption: rel.type, type: rel.type, width: styleConfig.width, color: styleConfig.color, disabled: hiddenNodeIds.has(rel.source) || hiddenNodeIds.has(rel.target), hovered: highlightedNodeIds.has(rel.source) || highlightedNodeIds.has(rel.target), }; });
- NvlOptions
interface NvlOptions { // Zoom & Pan initialZoom?: number; minZoom?: number; // Default: 0.075 maxZoom?: number; // Default: 10 allowDynamicMinZoom?: boolean; // Default: true — exceed minZoom if content doesn't fit panX?: number; panY?: number;
// Rendering renderer?: "webgl" | "canvas"; // Default: 'webgl' disableWebWorkers?: boolean; // Use synchronous layout fallback minimapContainer?: HTMLElement; // DOM element for minimap rendering
// Layout layout?: Layout; layoutOptions?: LayoutOptions;
// Instance instanceId?: string; callbacks?: ExternalCallbacks;
// Accessibility & Telemetry disableAria?: boolean; // Default: false disableTelemetry?: boolean; // Default: false
// Styling defaults styling?: { defaultNodeColor?: string; defaultRelationshipColor?: string; disabledItemColor?: string; disabledItemFontColor?: string; selectedBorderColor?: string; selectedInnerBorderColor?: string; dropShadowColor?: string; nodeDefaultBorderColor?: string; minimapViewportBoxColor?: string; iconStyle?: { scale?: number }; // e.g. 0.6 = 60% of node size }; }
FinSight options pattern
const nvlOptions = useMemo( () => ({ disableWebWorkers: true, renderer: "canvas" as const, minimapContainer: minimapElement || undefined, styling: { minimapViewportBoxColor: "#3B82F6", iconStyle: { scale: 0.6 }, }, }), [minimapElement], );
- Layout Configuration
type Layout = "forceDirected" | "hierarchical" | "d3Force" | "grid" | "free";
forceDirected (default)
interface ForceDirectedOptions { enableCytoscape?: boolean; // CoseBilkent for smaller graphs — better initial positioning enableVerlet?: boolean; // New physics engine (default: true) intelWorkaround?: boolean; // Fixes Intel GPU WebGL shader issues }
hierarchical
interface HierarchicalOptions { direction?: "left" | "right" | "up" | "down"; packing?: "bin" | "stack"; }
Changing layouts at runtime
// Via ref — use setTimeout to avoid race conditions with NVL initialization useEffect(() => { const timer = setTimeout(() => { nvlRef.current?.setLayout(layout); }, 50); return () => clearTimeout(timer); }, [layout]);
// Update layout options without changing algorithm nvlRef.current.setLayoutOptions({ direction: "up" });
// Check if layout is still animating nvlRef.current.isLayoutMoving(); // boolean
- Interaction Handlers
Used with BasicNvlWrapper (or vanilla JS) when you need manual control. InteractiveNvlWrapper handles these internally via mouseEventCallbacks .
import { ZoomInteraction, PanInteraction, ClickInteraction, DragNodeInteraction, HoverInteraction, BoxSelectInteraction, LassoInteraction, } from "@neo4j-nvl/interaction-handlers";
Registering handlers (via ref)
// Must wait for NVL initialization — use setTimeout useEffect(() => { const timer = setTimeout(() => { if (nvlRef.current) { new ZoomInteraction(nvlRef.current); new PanInteraction(nvlRef.current); } }); return () => clearTimeout(timer); }, []);
Handler classes and callbacks
Handler Constructor Callback Signature
ZoomInteraction
new ZoomInteraction(nvl)
onZoom
(zoomLevel: number) => void
PanInteraction
new PanInteraction(nvl)
onPan
(panning: any) => void
ClickInteraction
new ClickInteraction(nvl)
onNodeClick , onNodeDoubleClick , onNodeRightClick , onRelationshipClick , onRelationshipDoubleClick , onRelationshipRightClick , onSceneClick
Various
DragNodeInteraction
new DragNodeInteraction(nvl)
onDrag
(nodes: Node[]) => void
HoverInteraction
new HoverInteraction(nvl)
onHover
(element, hitTargets, event) => void
BoxSelectInteraction
new BoxSelectInteraction(nvl)
onBoxSelect
({ nodes, rels }) => void
LassoInteraction
new LassoInteraction(nvl)
onLassoSelect
({ nodes, rels }) => void
// Update callback after construction handler.updateCallback("onNodeClick", (node: Node) => { console.log("clicked", node); });
- NVL Instance Methods (via ref)
Graph Manipulation
// Add elements nvl.addElementsToGraph(nodes: Node[], relationships: Relationship[]): void
// Add new + update existing (matches by ID) nvl.addAndUpdateElementsInGraph(nodes?: Node[] | PartialNode[], rels?: Relationship[] | PartialRelationship[]): void
// Update properties on existing elements only nvl.updateElementsInGraph(nodes: Node[] | PartialNode[], rels: Relationship[] | PartialRelationship[]): void
// Remove by ID (removes adjacent relationships too) nvl.removeNodesWithIds(nodeIds: string[]): void nvl.removeRelationshipsWithIds(relIds: string[]): void
Data Retrieval
nvl.getNodes(): Node[] nvl.getNodeById(id: string): Node nvl.getRelationships(): Relationship[] nvl.getRelationshipById(id: string): Relationship nvl.getNodePositions(): (Node & Point)[] nvl.getPositionById(id: string): Node nvl.getNodesOnScreen(): { nodes: Node[]; rels: Relationship[] }
Viewport
nvl.setZoom(zoomValue: number): void nvl.resetZoom(): void // Resets to 0.75 nvl.getScale(): number nvl.setPan(panX: number, panY: number): void nvl.getPan(): Point // { x, y } nvl.setZoomAndPan(zoom: number, panX: number, panY: number): void nvl.fit(nodeIds: string[], zoomOptions?: ZoomOptions): void
type ZoomOptions = { animated?: boolean; maxZoom?: number; minZoom?: number; noPan?: boolean; // Zoom without panning outOnly?: boolean; // Only zoom out, never in };
Selection
nvl.getSelectedNodes(): (Node & Point)[] nvl.getSelectedRelationships(): Relationship[] nvl.deselectAll(): void
Node Positioning
nvl.setNodePositions(data: Node[], updateLayout?: boolean): void nvl.pinNode(nodeId: string): void nvl.unPinNode(nodeIds: string[]): void
Layout
nvl.setLayout(layout: Layout): void nvl.setLayoutOptions(options: LayoutOptions): void nvl.isLayoutMoving(): boolean
Export
nvl.saveToFile(options?: { backgroundColor?: string; filename?: string }): void nvl.saveFullGraphToLargeFile(options?: { backgroundColor?: string; filename?: string }): void nvl.getImageDataUrl(options?: { backgroundColor?: string }): string
Hit Detection
nvl.getHits( evt: MouseEvent, targets?: ('node' | 'relationship')[], hitOptions?: { hitNodeMarginWidth: number } ): NvlMouseEvent
Lifecycle
nvl.restart(options?: NvlOptions, retainPositions?: boolean): void nvl.destroy(): void // MUST call on unmount nvl.getCurrentOptions(): NvlOptions nvl.getContainer(): HTMLElement nvl.setRenderer(renderer: string): void nvl.setDisableWebGL(disabled?: boolean): void // Experimental
ExternalCallbacks (lifecycle hooks)
interface ExternalCallbacks { onError?: (error: Error) => void; onInitialization?: () => void; onLayoutComputing?: (isComputing: boolean) => void; onLayoutDone?: () => void; onLayoutStep?: (nodes: Node[]) => void; onWebGLContextLost?: (event: WebGLContextEvent) => void; onZoomTransitionDone?: () => void; restart?: () => void; }
- Patterns (from FinSight)
Label-based node style registry
Centralized config mapping node labels to visual properties:
type NodeStyleConfig = { icon: string; // Lucide icon name baseColor: string; // Hex color baseSize: number; // Node size shape: string; };
const nodeStyleConfig: Record<string, NodeStyleConfig> = { Customer: { icon: "User", baseColor: "#3B82F6", baseSize: 30, shape: "circle", }, Account: { icon: "Landmark", baseColor: "#10B981", baseSize: 28, shape: "circle", }, Transaction: { icon: "ArrowLeftRight", baseColor: "#F59E0B", baseSize: 24, shape: "circle", }, // ... };
function getNodeStyleConfig(label: string): NodeStyleConfig { return nodeStyleConfig[label] ?? defaultConfig; }
Lucide icon → SVG data URI with caching
NVL icon expects a URL or data URI. Convert Lucide React components:
import { renderToStaticMarkup } from 'react-dom/server';
const iconCache = new Map<string, string>();
function getCachedIconDataUri(iconName: string, color: string): string {
const key = ${iconName}-${color};
if (iconCache.has(key)) return iconCache.get(key)!;
const IconComponent = getLucideIcon(iconName);
const svgString = renderToStaticMarkup(<IconComponent color={color} size={24} />);
const dataUri = data:image/svg+xml,${encodeURIComponent(svgString)};
iconCache.set(key, dataUri);
return dataUri;
}
Critical: Pass '#000000' (black) as the icon color. NVL handles color inversion for dark node backgrounds automatically.
Node display name formatting
Format captions based on node label:
function getNodeDisplayName(node: GraphNode): string {
const label = getPrimaryLabel(node);
switch (label) {
case "Customer":
return ${node.properties.firstName} ${node.properties.lastName};
case "Account":
return Account ***${node.properties.accountNumber?.slice(-4)};
case "Transaction":
return TXN-${node.properties.amount};
case "Email":
return node.properties.address;
case "Phone":
return node.properties.number;
default:
return node.properties.name ?? node.id;
}
}
Visibility filtering
Filter out removed/hidden nodes before passing to NVL:
const visibleNodes = nodes.filter((n) => !removedNodeIds.has(n.id)); const visibleRelationships = relationships.filter( (r) => !removedNodeIds.has(r.source) && !removedNodeIds.has(r.target), ); // Hidden (but not removed) nodes: pass to NVL with disabled: true
Minimap setup
Create the minimap container via useLayoutEffect (before NVL initializes), then pass as option:
const [minimapElement, setMinimapElement] = useState<HTMLDivElement | null>(null);
useLayoutEffect(() => { const minimapDiv = document.createElement('div'); minimapDiv.className = 'absolute bottom-4 right-4 w-64 h-64 rounded-lg border bg-white'; minimapDiv.style.pointerEvents = 'auto'; graphContainerRef.current!.appendChild(minimapDiv); setMinimapElement(minimapDiv); return () => { minimapDiv.remove(); setMinimapElement(null); }; }, []);
// Pass to NVL — only render when element is ready const nvlOptions = useMemo(() => ({ minimapContainer: minimapElement || undefined, }), [minimapElement]);
// Guard rendering on minimapElement {nvlNodes.length > 0 && minimapElement ? ( <InteractiveNvlWrapper nvlOptions={nvlOptions} ... /> ) : null}
Stats bar
Show node/relationship counts above the graph:
<div className="border-b px-4 py-2 flex items-center gap-4 text-sm"> <div> Nodes: <span className="font-medium">{visibleNodes.length}</span> </div> <div> Relationships:{" "} <span className="font-medium">{visibleRelationships.length}</span> </div> {hiddenCount > 0 && ( <div> Hidden: <span className="text-orange-600">{hiddenCount}</span> </div> )} </div>
Layout change with setTimeout guard
NVL needs a tick to initialize before setLayout calls work:
useEffect(() => { if (!nvlRef.current) return; const timer = setTimeout(() => { nvlRef.current?.setLayout(layout); }, 50); return () => clearTimeout(timer); }, [layout]);
Resize handling
Hide graph during panel resize to avoid NVL rendering glitches, show on release:
<div style={{ opacity: isResizing ? 0 : 1, pointerEvents: isResizing ? 'none' : 'auto', transition: 'opacity 0.15s ease-out' }}> <InteractiveNvlWrapper ... /> </div> {isResizing && ( <div className="absolute inset-0 flex items-center justify-center"> <p>Release to view graph</p> </div> )}
Zoom controls via ref
const handleZoomIn = useCallback(() => { if (nvlRef.current) { nvlRef.current.setZoom(nvlRef.current.getScale() * 1.2); } }, []);
const handleFitView = useCallback(() => { if (nvlRef.current && visibleNodes.length > 0) { nvlRef.current.fit(visibleNodes.map((n) => n.id)); } }, [visibleNodes]);
Export image
const handleExportImage = useCallback(() => {
if (nvlRef.current) {
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, -5);
nvlRef.current.saveToFile({ filename: graph-${timestamp}.png });
}
}, []);
- Anti-Patterns
Anti-Pattern Why It Fails Fix
Missing container height NVL renders into a 0-height div and nothing appears Ensure parent has explicit height (h-full , flex-1 , or fixed px)
Setting both caption and captions
captions array wins and caption is ignored silently Use one or the other
Non-black icons NVL expects black SVGs; it applies node color automatically Always pass color: '#000000' when generating icon data URIs
Non-square icon images Icons render distorted Use square images/SVGs (e.g. 24x24)
Forgetting destroy() on unmount Memory leak — canvas, event listeners, workers stay alive Call nvl.destroy() in cleanup (React wrappers handle this)
WebGL renderer + captions/arrowheads Some caption and arrowhead features only work with canvas renderer Use renderer: 'canvas' when captions or arrowheads are needed
Calling setLayout immediately after init NVL hasn't finished initializing — call is silently dropped Wrap in setTimeout(() => {}, 50) or use onInitialization callback
Arbitrary waitForTimeout for layout Brittle timing — may fire too early or too late Use isLayoutMoving() or the onLayoutDone callback instead
Non-unique IDs across nodes and relationships NVL requires IDs unique across the entire graph — not just within nodes or rels Prefix or namespace IDs if source data may collide
Mutating node/rel arrays in place React wrappers diff by reference — mutations are invisible Always create new arrays: [...nodes] or .map()