nvl

SKILL.md

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.


1. 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.

2. 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.


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

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

5. 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],
);

6. 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

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

8. 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;
}

9. 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` });
  }
}, []);

10. 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()
Weekly Installs
5
GitHub Stars
1
First Seen
13 days ago
Installed on
opencode5
mcpjam3
claude-code3
junie3
windsurf3
zencoder3