
Neo4j Nvl Skill
- 271 installs
- 101 repo stars
- Updated August 3, 2026
- neo4j-contrib/neo4j-skills
Embed interactive Neo4j graph visualizations in a React or canvas/WebGL app using the official NVL packages.
About
neo4j-nvl-skill is an agent skill for solo builders shipping graph-aware SaaS or internal tools who need Neo4j Visualization Library (NVL) wired correctly the first time. It walks through @neo4j-nvl/base for nodes, relationships, rendering options, and export, @neo4j-nvl/interaction-handlers for zoom, pan, selection, and keyboard UX, and @neo4j-nvl/react wrappers for React 19 apps. You learn when to pick Canvas2D versus WebGL2, how to choose layouts from forceDirected through d3Force, and how to connect live Cypher results via nvlResultTransformer instead of hand-mapping records. The skill deliberately excludes Needle’s GraphVisualization defaults, Python/Jupyter ports, raw Cypher authoring, driver lifecycle, and GDS—pointing you to sibling Neo4j skills for those concerns. Use it while building dashboards, agent memory graphs, or analytics UIs on Neo4j 1.1+ in modern browsers.
- Documents @neo4j-nvl/base NVL class, options, callbacks, hit testing, and image export
- Covers nine interaction handlers including zoom, pan, drag, box-select, lasso, and keyboard
- Three React wrappers: InteractiveNvlWrapper, BasicNvlWrapper, StaticPictureWrapper
- Canvas vs WebGL renderer guidance (~1k vs 100k+ nodes) plus six layout modes
- nvlResultTransformer pipes driver.executeQuery output directly into NVL
Neo4j Nvl Skill by the numbers
- 271 all-time installs (skills.sh)
- +29 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #770 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/neo4j-contrib/neo4j-skills --skill neo4j-nvl-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 271 |
|---|---|
| repo stars | ★ 101 |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | neo4j-contrib/neo4j-skills ↗ |
What it does
Embed interactive Neo4j graph visualizations in a React or canvas/WebGL app using the official NVL packages.
Files
When to Use
- Rendering a Neo4j graph in a browser (vanilla JS, React, Vite) with custom interactions, rendering, or data shapes
- Visualizing
driver.executeQueryresults as an interactive graph - Wiring zoom, pan, drag, click, hover, lasso, or box-select interactions
- Embedding NVL inside an existing app and synchronizing graph state
When NOT to Use
- Pre-styled embedded graph view with default behavior, no custom interactions →
GraphVisualizationfrom@neo4j-ndl/react(Neo4j Needle / NDL design system) — wraps NVL with default Neo4j styling. See Use NVL or the Needle Component? below. - Python / Jupyter notebook graph visualization →
neo4j/python-graph-visualization(the Python port of NVL) - Writing/optimizing Cypher →
neo4j-cypher-skill - Driver setup / executeQuery / sessions →
neo4j-driver-javascript-skill - Server-side data fetching with no rendering →
neo4j-driver-javascript-skill - GDS algorithm execution →
neo4j-gds-skillorneo4j-aura-graph-analytics-skill - GraphQL API →
neo4j-graphql-skill
---
Use NVL or the Needle Component?
| Need | Use |
|---|---|
| Embed a graph view with default Neo4j styling, no custom interactions or rendering | GraphVisualization from @neo4j-ndl/react (Neo4j Needle / NDL design system) — wraps NVL and accepts records shaped { id, labels, properties: { key: { stringified, type } } } (NeoNode) |
| Custom interactions, custom rendering, non-standard data shapes, or framework-agnostic embedding | This skill — use NVL directly |
If the answer is the first row, install and use the Needle component instead of NVL — do not duplicate styling work.
---
Install
npm install @neo4j-nvl/base # core (required)
npm install @neo4j-nvl/interaction-handlers # standard interactions (optional, vanilla JS)
npm install @neo4j-nvl/react # React wrappers (optional)Peer requirements: React 19 for @neo4j-nvl/react. The published peerDependency range still permits React 18, but mixing major versions is not recommended — target 19. @neo4j-nvl/layout-workers is a transitive dependency — never install directly. neo4j-driver is a peer of @neo4j-nvl/base only when using nvlResultTransformer.
Starter templates: https://github.com/neo4j-devtools/nvl-boilerplates — official per-framework scaffolds; prefer these over hand-rolled setups.
License: NVL ships under the Neo4j Visualization Library License — for use with Neo4j products only. Cannot be used against other graph backends.
---
Pick the Right Paradigm
| Need | Use |
|---|---|
| React app, default interactions | <InteractiveNvlWrapper> from @neo4j-nvl/react |
| React app, custom interaction wiring | <BasicNvlWrapper> + own handlers via ref |
| Vanilla JS, standard interactions | NVL + @neo4j-nvl/interaction-handlers |
| Vanilla JS, fully custom event logic | NVL + container.addEventListener + nvl.getHits() |
| Static PNG/SVG image export | <StaticPictureWrapper> or nvl.saveToFile() / nvl.saveToSvg() |
---
Pick the Right Renderer
| Renderer | Max nodes | Detail | Use case |
|---|---|---|---|
'canvas' (default) | ~1,000 | Full captions, icons, arrows, pixel-perfect hit-testing | Detail investigation, small graphs |
'webgl' | 100,000+ | Reduced label fidelity (bound by GPU max texture size) | Large-scale pattern exploration |
const nvl = new NVL(container, nodes, rels, { renderer: 'webgl' })
nvl.setRenderer('canvas') // swap at runtime---
Container Setup
The container must have an explicit width AND height. Missing height → container collapses to 0 → graph invisible. Most-reported NVL bug.
<!-- ❌ height defaults to 0; graph invisible -->
<div id="viz"></div>
<!-- ✅ explicit dimensions -->
<div id="viz" style="width: 100%; height: 600px;"></div>---
Vanilla — Base Library
import { NVL } from '@neo4j-nvl/base'
const container = document.getElementById('viz')
const nodes = [{ id: '1' }, { id: '2' }]
const relationships = [{ id: '12', from: '1', to: '2', type: 'KNOWS' }]
const nvl = new NVL(container, nodes, relationships)With options + callbacks:
import { NVL } from '@neo4j-nvl/base'
const options = {
initialZoom: 1.0,
minZoom: 0.1,
maxZoom: 8,
layout: 'forceDirected',
renderer: 'canvas',
styling: { defaultNodeColor: '#0e86d4', defaultRelationshipColor: '#888' }
}
const callbacks = {
onInitialization: () => console.log('NVL ready'),
onLayoutDone: () => nvl.fit([]),
onError: (err) => console.error('NVL error', err)
}
const nvl = new NVL(container, nodes, relationships, options, callbacks)
// On teardown — always:
nvl.destroy()NVL constructor signature: new NVL(frame, nvlNodes?, nvlRels?, options?, callbacks?). All but frame are optional and default to empty.
---
Vanilla — Interaction Handlers
Compose handlers onto an existing NVL instance. Each handler registers callbacks via .updateCallback(name, fn) and must be torn down with .destroy().
import { NVL } from '@neo4j-nvl/base'
import {
ZoomInteraction, PanInteraction, DragNodeInteraction,
ClickInteraction, HoverInteraction, BoxSelectInteraction,
LassoInteraction, KeyboardInteraction
} from '@neo4j-nvl/interaction-handlers'
const nvl = new NVL(container, nodes, relationships)
const zoom = new ZoomInteraction(nvl)
const pan = new PanInteraction(nvl)
const drag = new DragNodeInteraction(nvl)
const click = new ClickInteraction(nvl, { selectOnClick: true })
const hover = new HoverInteraction(nvl, { drawShadowOnHover: true })
click.updateCallback('onNodeClick', (node, hits, evt) => console.log('node', node.id))
click.updateCallback('onRelationshipClick', (rel, hits, evt) => console.log('rel', rel.id))
click.updateCallback('onCanvasClick', (evt) => console.log('canvas'))
hover.updateCallback('onHover', (el, hits, evt) => el && console.log('over', el.id))
drag.updateCallback('onDragEnd', (nodes, evt) => savePositions(nodes))
zoom.updateCallback('onZoom', (level) => console.log('zoom', level))
// Teardown — destroy all handlers, then the NVL instance
function teardown() {
for (const h of [zoom, pan, drag, click, hover]) h.destroy()
nvl.destroy()
}Disable an event without removing the handler: click.removeCallback('onCanvasClick'). Passing true instead of a function enables the event with a no-op (useful for default selection behavior).
---
React — InteractiveNvlWrapper
Pre-wires every interaction handler. Toggle events with mouseEventCallbacks (function = on + callback; true = on, no-op; false/omit = off).
import { InteractiveNvlWrapper } from '@neo4j-nvl/react'
import type { MouseEventCallbacks, NvlOptions } from '@neo4j-nvl/react'
import { useRef } from 'react'
import type { NVL } from '@neo4j-nvl/base'
export function GraphView({ nodes, rels }) {
const nvlRef = useRef<NVL>(null)
const nvlOptions: NvlOptions = { initialZoom: 1, renderer: 'canvas' }
const mouseEventCallbacks: MouseEventCallbacks = {
onNodeClick: (node, hits, evt) => console.log('node', node.id),
onRelationshipClick: (rel, hits, evt) => console.log('rel', rel.id),
onCanvasClick: (evt) => console.log('canvas'),
onHover: (el, hits, evt) => el && console.log('hover', el.id),
onDragEnd: (nodes, evt) => persist(nodes),
onZoom: true, // enable, no callback
onPan: true
}
return (
<div style={{ width: '100%', height: 600 }}>
<InteractiveNvlWrapper
ref={nvlRef}
nodes={nodes}
rels={rels}
nvlOptions={nvlOptions}
interactionOptions={{ selectOnClick: true, drawShadowOnHover: true }}
mouseEventCallbacks={mouseEventCallbacks}
onInitializationError={(err) => console.error('NVL init', err)}
/>
</div>
)
}ref resolves to the underlying NVL instance — call any method on it: nvlRef.current?.fit([]), nvlRef.current?.setRenderer('webgl'), nvlRef.current?.saveToFile().
---
React — BasicNvlWrapper + Ref
No interactions wired. The ref exposes every NVL method via IncludeMethods<NVL> — use when building custom interaction logic in React.
import { BasicNvlWrapper } from '@neo4j-nvl/react'
import type { NVL } from '@neo4j-nvl/base'
import { useRef } from 'react'
export function MiniGraph({ nodes, rels }) {
const nvlRef = useRef<NVL>(null)
return (
<div style={{ width: '100%', height: 400 }}>
<BasicNvlWrapper
ref={nvlRef}
nodes={nodes}
rels={rels}
nvlOptions={{ initialZoom: 2 }}
nvlCallbacks={{ onLayoutDone: () => nvlRef.current?.fit([]) }}
/>
<button onClick={() => nvlRef.current?.fit(['1', '2'])}>Zoom to 1,2</button>
</div>
)
}---
Wiring a Neo4j Driver Result
@neo4j-nvl/base exports a ResultTransformer for the JS driver that deduplicates nodes/relationships across any record shape.
import neo4j from 'neo4j-driver'
import { NVL, nvlResultTransformer } from '@neo4j-nvl/base'
const driver = neo4j.driver(process.env.NEO4J_URI,
neo4j.auth.basic(process.env.NEO4J_USERNAME, process.env.NEO4J_PASSWORD))
const { nodes, relationships } = await driver.executeQuery(
'MATCH (a)-[r]-(b) RETURN a, r, b LIMIT 25',
{},
{ database: 'neo4j', resultTransformer: nvlResultTransformer }
)
const nvl = new NVL(document.getElementById('viz'), nodes, relationships)// ❌ raw EagerResult — records are not Node/Relationship objects
const result = await driver.executeQuery('MATCH (a)-[r]-(b) RETURN a, r, b')
new NVL(container, result.records, []) // breaks
// ✅ use the transformer
const { nodes, relationships } = await driver.executeQuery(
'MATCH (a)-[r]-(b) RETURN a, r, b',
{},
{ database: 'neo4j', resultTransformer: nvlResultTransformer }
)
new NVL(container, nodes, relationships)For driver lifecycle, session management, Integer handling, and TypeScript types → neo4j-driver-javascript-skill.
---
Updating the Graph
| Method | Behavior |
|---|---|
addAndUpdateElementsInGraph(nodes, rels) | Insert new; update existing by id (only specified fields) |
updateElementsInGraph(nodes, rels) | Update existing only; ignores unknown ids |
addElementsToGraph(nodes, rels) | Insert only; throws on existing id |
removeNodesWithIds(ids) | Remove nodes; adjacent relationships auto-removed |
removeRelationshipsWithIds(ids) | Remove relationships |
setNodePositions(nodes, updateLayout?) | Override positions; optionally re-run layout |
restart(options?, retainPositions?) | Restart with new options; positions optional |
Diff updates use PartialNode / PartialRelationship — only id is required:
nvl.updateElementsInGraph(
[{ id: '1', color: '#f00', selected: true }], // PartialNode
[{ id: '12', width: 4 }] // PartialRelationship
)---
Hit Testing (Manual)
Use when NOT using the interaction-handlers package. getHits() resolves which node/relationship is under a pointer event.
const nvl = new NVL(container, nodes, rels)
container.addEventListener('click', (evt) => {
const { nvlTargets } = nvl.getHits(evt, ['node', 'relationship'], { hitNodeMarginWidth: 4 })
const hitNode = nvlTargets.nodes[0]
const hitRel = nvlTargets.relationships[0]
if (hitNode) console.log('hit node', hitNode.data.id)
else if (hitRel) console.log('hit rel', hitRel.data.id)
else console.log('hit canvas')
})HitTargetNode / HitTargetRelationship carry data, pointerCoordinates, distance, insideNode (nodes only). See references/api-surface.md.
---
Common Mistakes
| Mistake | Fix |
|---|---|
Container with no height → invisible graph | Set explicit width and height on the container |
Pass driver.executeQuery result directly | Use nvlResultTransformer and consume { nodes, relationships } |
| WebGL for small label-rich graphs | Use 'canvas'; labels are fully supported |
| Canvas for 10k+ nodes | Switch to 'webgl' via renderer option or setRenderer |
New NVL per React render | Use <InteractiveNvlWrapper> / <BasicNvlWrapper> or wrap in useEffect + destroy() |
Forgetting nvl.destroy() on teardown | Call destroy() on unmount; React wrappers handle this automatically |
| Vanilla handlers not torn down | Call .destroy() on every interaction before nvl.destroy() |
| Worker construction blocked (strict CSP / sandboxed runtime / older bundler) | nvlOptions: { disableWebWorkers: true } (NVL has a non-worker fallback) |
| Telemetry enabled in regulated env | nvlOptions: { disableTelemetry: true } |
| Layout never settles | Pin anchor nodes with pinNode(id); tune layoutTimeLimit |
selectOnClick fires double | Toggle once at mount; don't flip interactionOptions per render |
| Hit test misses near node edge | Pass { hitNodeMarginWidth: N } to getHits |
| Captions missing on WebGL | GPU max texture size exceeded; fall back to Canvas or shrink captions |
---
References
Load on demand:
- references/api-surface.md — complete
NVLmethod table;Node,Relationship,NvlOptions,LayoutOptions,ExternalCallbacks,HitTargets,NvlMouseEvent,StyledCaption,Point; every interaction-handler class + its options + its callback signatures; React<InteractiveNvlWrapper>/<BasicNvlWrapper>/<StaticPictureWrapper>props;MouseEventCallbacksandKeyboardEventCallbacksshapes; named exports inventory;nvlResultTransformersignature - references/troubleshooting.md — zero-height container, build-tool-agnostic
disableWebWorkersfallback, Canvas/WebGL trade-offs + WebGL2 note, WebGL texture-size cap,onWebGLContextLostrecovery, telemetry opt-out, memory leaks, stuck layouts, double selection, hit-margin tuning, license restriction
Canonical web documentation (use WebFetch when references above are insufficient):
- https://neo4j.com/docs/nvl/current/ — user guide (installation, base library, interaction handlers, React wrappers)
- https://neo4j.com/docs/api/nvl/current/ — TypeDoc API reference
- https://neo4j.com/docs/api/nvl/current/examples.html — runnable examples
- https://github.com/neo4j-devtools/nvl-boilerplates — official starter templates per supported framework
- https://github.com/neo4j/python-graph-visualization — Python port of NVL (use this skill only for the JavaScript/browser path)
---
Checklist
- [ ] Container has explicit
widthANDheightCSS - [ ] Correct paradigm chosen from the decision table (vanilla / handlers / React)
- [ ] Renderer matches expected node count (Canvas ≲1k / WebGL 100k+)
- [ ] Driver
executeQueryresults piped throughnvlResultTransformer - [ ]
databasespecified on everyexecuteQuerycall (delegate toneo4j-driver-javascript-skill) - [ ] All interaction handlers
.destroy()-ed beforenvl.destroy()on teardown - [ ]
nvl.destroy()called on React unmount (manual instances only — wrappers handle it) - [ ]
disableTelemetry: trueset when in regulated / offline environments - [ ]
disableWebWorkers: trueset when bundler / CSP blocks worker construction - [ ] Graph updates use
addAndUpdateElementsInGraph/updateElementsInGraph— notrestart - [ ] License compatible: target is a Neo4j product
neo4j-nvl-skill
Skill for the Neo4j Visualization Library (NVL) — covering:
@neo4j-nvl/base—NVLclass, nodes/relationships, options, callbacks, hit testing, image export@neo4j-nvl/interaction-handlers—ZoomInteraction,PanInteraction,DragNodeInteraction,ClickInteraction,HoverInteraction,BoxSelectInteraction,LassoInteraction,KeyboardInteraction@neo4j-nvl/react—InteractiveNvlWrapper,BasicNvlWrapper,StaticPictureWrappernvlResultTransformerfor pipingdriver.executeQueryresults straight into NVL- Canvas vs WebGL renderer selection (~1k vs 100k+ nodes)
- Layout selection (
forceDirected,hierarchical,circular,grid,free,d3Force) - Container setup, Web Worker fallback, telemetry opt-out
Not covered (see sibling tools / skills):
- Out-of-the-box embedded graph view with default styling →
GraphVisualizationcomponent in@neo4j-ndl/react(Neo4j Needle design system) — wraps NVL with defaults - Python / Jupyter graph visualization →
neo4j/python-graph-visualization(Python port of NVL) - Cypher query authoring →
neo4j-cypher-skill - Driver lifecycle, sessions,
executeQuerysetup →neo4j-driver-javascript-skill - GDS algorithms →
neo4j-gds-skill/neo4j-aura-graph-analytics-skill
Compatibility: @neo4j-nvl/base 1.1+; React 19 for @neo4j-nvl/react; modern browsers with Canvas2D + WebGL2.
Install:
npm install @neo4j-nvl/base
npm install @neo4j-nvl/interaction-handlers # optional, for vanilla JS interactions
npm install @neo4j-nvl/react # optional, for React appsStarter templates: https://github.com/neo4j-devtools/nvl-boilerplates
Install skill:
npx skills add https://github.com/neo4j-contrib/neo4j-skillsNVL — API Surface Reference
Source: @neo4j-nvl/base@1.1, @neo4j-nvl/interaction-handlers@1.1, @neo4j-nvl/react@1.1.
Packages
| Package | Main export | Notes |
|---|---|---|
@neo4j-nvl/base | NVL (also default), types, nvlResultTransformer, layout constants | Framework-agnostic core |
@neo4j-nvl/interaction-handlers | ZoomInteraction, PanInteraction, ClickInteraction, HoverInteraction, DragNodeInteraction, BoxSelectInteraction, LassoInteraction, KeyboardInteraction | Compose onto an NVL instance |
@neo4j-nvl/react | InteractiveNvlWrapper, BasicNvlWrapper, StaticPictureWrapper | React 19 |
@neo4j-nvl/layout-workers | (transitive) | Force-directed + hierarchical workers |
---
NVL Class
Constructor
new NVL(
frame: HTMLElement,
nvlNodes?: Node[], // default []
nvlRels?: Relationship[], // default []
options?: NvlOptions, // default {}
callbacks?: ExternalCallbacks // default {}
)Element CRUD
| Method | Signature | Purpose |
|---|---|---|
addAndUpdateElementsInGraph | `(nodes: Node[] \ | PartialNode[], rels: Relationship[] \ |
addElementsToGraph | (nodes: Node[], rels: Relationship[]) => void | Insert only |
updateElementsInGraph | (nodes: PartialNode[], rels: PartialRelationship[]) => void | Update existing only; ignores unknown ids |
removeNodesWithIds | (ids: string[]) => void | Remove nodes + adjacent relationships |
removeRelationshipsWithIds | (ids: string[]) => void | Remove relationships |
Element Read
| Method | Signature |
|---|---|
getNodes | () => Node[] |
getRelationships | () => Relationship[] |
getNodeById | (id: string) => Node |
getRelationshipById | (id: string) => Relationship |
getPositionById | (id: string) => Node |
getNodesOnScreen | () => { nodes: Node[]; rels: Relationship[] } |
getNodePositions | () => (Node & Point)[] |
setNodePositions | (data: Node[], updateLayout?: boolean) => void |
getSelectedNodes | () => (Node & Point)[] |
getSelectedRelationships | () => Relationship[] |
deselectAll | () => void |
Viewport
| Method | Signature |
|---|---|
fit | (nodeIds: string[], zoomOptions?: ZoomOptions) => void (empty array = fit all) |
resetZoom | () => void |
setZoom | (zoom: number) => void |
setPan | (panX: number, panY: number) => void |
setZoomAndPan | (zoom: number, panX: number, panY: number) => void |
getScale | () => number |
getPan | () => Point |
getZoomLimits | () => { minZoom: number; maxZoom: number } |
Layout / Renderer / Pin
| Method | Signature |
|---|---|
setLayout | (layout: Layout) => void |
setLayoutOptions | (options: LayoutOptions) => void |
isLayoutMoving | () => boolean |
setRenderer | `(renderer: 'canvas' \ |
setDisableWebGL | (disabled?: boolean) => void |
pinNode | (nodeId: string) => void |
unPinNode | (nodeIds: string[]) => void |
Export / Save
| Method | Signature |
|---|---|
saveToFile | (opts?: { filename?: string; backgroundColor?: string }) => void (PNG of current view) |
saveFullGraphToLargeFile | (opts?: { filename?: string; backgroundColor?: string }) => void (PNG of full graph) |
saveToSvg | (opts?: { filename?: string; backgroundColor?: string }) => void (experimental) |
getImageDataUrl | (opts?: { backgroundColor?: string }) => string |
getSvgDataUrl | (opts?: { backgroundColor?: string }) => Promise<string> |
Hit Testing
getHits(
evt: MouseEvent,
targets?: ('node' | 'relationship')[], // default ['node','relationship']
hitOptions?: { hitNodeMarginWidth: number } // default 0
): NvlMouseEventLifecycle / Misc
| Method | Signature |
|---|---|
getCurrentOptions | () => NvlOptions |
getContainer | () => HTMLElement |
restart | (options?: NvlOptions, retainPositions?: boolean) => void |
destroy | () => void |
---
Core Types
Node
| Field | Type | Notes |
|---|---|---|
id | string | Required; unique across nodes AND relationships |
caption | string | Single caption (superseded by captions) |
captions | StyledCaption[] | Multi-line captions with styles |
captionSize | number | Text size |
captionAlign | `'top' \ | 'bottom' \ |
color | string | Fill color |
size | number | Radius |
pinned | boolean | Excluded from layout forces |
x / y | number | Position |
selected | boolean | |
hovered | boolean | |
activated | boolean | |
disabled | boolean | |
icon | string | URL to image inside node |
html | HTMLElement | DOM overlay |
overlayIcon | { url: string; position?: number[]; size?: number } |
Relationship
| Field | Type | Notes |
|---|---|---|
id | string | Required; unique across nodes AND relationships |
from | string | Source node id |
to | string | Target node id |
type | string | Relationship type label |
width | number | |
color | string | |
caption / captions / captionSize / captionAlign | (same as Node) | |
captionHtml | HTMLElement | DOM overlay on caption |
selected / hovered / disabled | boolean |
Partial types
type PartialNode = Partial<Node> & { id: string }
type PartialRelationship = Partial<Relationship> & { id: string }---
NvlOptions
| Field | Type | Default | Notes |
|---|---|---|---|
instanceId | string | — | |
layout | Layout | 'forceDirected' | See Layout enum below |
layoutOptions | LayoutOptions | — | Layout-specific |
layoutTimeLimit | number | — | ms cap for layout iteration |
minZoom | number | 0.075 | |
maxZoom | number | 10 | |
allowDynamicMinZoom | boolean | true | Permits going below minZoom to fit |
initialZoom | number | — | |
panX / panY | number | — | |
renderer | `'canvas' \ | 'webgl'` | 'canvas' |
disableWebGL | boolean | false | Force-off WebGL even if requested |
disableWebWorkers | boolean | false | Use synchronous layout fallback |
disableTelemetry | boolean | false | |
disableAria | boolean | false | |
minimapContainer | `HTMLElement \ | null` | — |
styling | StylingOptions | — | |
callbacks | ExternalCallbacks | — | (also passable as 5th constructor arg) |
logging | { level: LogLevelDesc } | — |
StylingOptions
| Field | Type |
|---|---|
defaultNodeColor | string |
defaultRelationshipColor | string |
nodeDefaultBorderColor | string |
selectedBorderColor | string |
selectedInnerBorderColor | string |
dropShadowColor | string |
disabledItemColor | string |
disabledItemFontColor | string |
minimapViewportBoxColor | string |
Layout enum + constants
type Layout = 'forceDirected' | 'hierarchical' | 'grid' | 'free' | 'd3Force' | 'circular'
// Exported constants (string literals):
ForceDirectedLayoutType // 'forceDirected'
HierarchicalLayoutType // 'hierarchical'
GridLayoutType // 'grid'
FreeLayoutType // 'free'
d3ForceLayoutType // 'd3Force'
CircularLayoutType // 'circular'LayoutOptions union
type LayoutOptions = ForceDirectedOptions | HierarchicalOptions | CircularOptions
interface ForceDirectedOptions {
intelWorkaround?: boolean // workaround for Intel GPU shader issues; requires restart
enableCytoscape?: boolean // deprecated; auto-cose for small graphs
}
interface HierarchicalOptions {
direction?: 'up' | 'down' | 'left' | 'right'
packing?: 'bin' | 'stack'
}
interface CircularOptions {
sortFunction?: (nodes: Node[]) => Node[]
}ZoomOptions
| Field | Type | Purpose |
|---|---|---|
noPan | boolean | Zoom without changing pan |
outOnly | boolean | Only zoom out to fit |
minZoom | number | Override for this op |
maxZoom | number | Override for this op |
animated | boolean | Animate transition |
Renderer constants
type Renderer = 'webgl' | 'canvas'
WebGLRendererType // 'webgl'
CanvasRendererType // 'canvas'StyledCaption
type StyledCaption = {
styles: string[] // e.g. ['bold','italic']
value: string
key: string
}Point
type Point = { x: number; y: number }---
ExternalCallbacks
| Callback | Signature |
|---|---|
onInitialization | () => void |
onLayoutDone | () => void |
onLayoutStep | (nodes: Node[]) => void |
onLayoutComputing | (isComputing: boolean) => void |
onError | (error: Error) => void |
onWebGLContextLost | (event: WebGLContextEvent) => void |
onZoomTransitionDone | () => void |
restart | () => void |
---
Hit Testing Types
interface NvlMouseEvent extends MouseEvent {
nvlTargets: HitTargets
}
type HitTargets = {
nodes: HitTargetNode[]
relationships: HitTargetRelationship[]
}
interface HitTargetNode {
data: Node
targetCoordinates: Point
pointerCoordinates: Point
distanceVector: Point
distance: number
insideNode: boolean
}
interface HitTargetRelationship {
data: Relationship
fromTargetCoordinates: Point
toTargetCoordinates: Point
pointerCoordinates: Point
distance: number
}---
nvlResultTransformer
import type { ResultTransformer, EagerResult } from 'neo4j-driver'
const nvlResultTransformer: ResultTransformer<
EagerResult,
{ nodes: Node[]; relationships: Relationship[] }
>Usage: driver.executeQuery(cypher, params, { database: 'neo4j', resultTransformer: nvlResultTransformer }). Returns deduplicated { nodes, relationships }.
---
Named Exports — @neo4j-nvl/base
// values
export {
NVL, // also default export
nvlResultTransformer,
colorMapperFunctions,
CompatibilityError,
drawCircleBand,
getZoomTargetForNodePositions,
ForceDirectedLayoutType,
HierarchicalLayoutType,
GridLayoutType,
FreeLayoutType,
d3ForceLayoutType,
CircularLayoutType
}
// types
export type {
NvlOptions, Renderer, Node, Relationship, PartialNode, PartialRelationship,
Layout, LayoutOptions, ForceDirectedOptions, HierarchicalOptions, CircularOptions,
ExternalCallbacks, HitTargets, HitTargetNode, HitTargetRelationship,
Point, NvlMouseEvent, ZoomOptions, StyledCaption,
WebGLRendererType, CanvasRendererType
}---
Interaction Handlers
All extend BaseInteraction<P>:
class BaseInteraction<P> {
constructor(nvl: NVL, options?: P)
updateCallback(name: string, callback: ((...args: unknown[]) => void) | boolean): void
removeCallback(name: string): void
destroy(): void
readonly currentOptions: P
}Callback semantics: function = enable + invoke; true = enable as no-op; omitted/removed = disable.
ZoomInteraction
new ZoomInteraction(nvl, options?: { controlledZoom?: boolean })| Event | Signature |
|---|---|
onZoom | (zoomLevel: number, event: WheelEvent) => void |
onZoomAndPan | (zoomLevel: number, panX: number, panY: number, event: WheelEvent) => void |
PanInteraction
new PanInteraction(nvl, options?: { excludeNodeMargin?: boolean; controlledPan?: boolean })
panInteraction.updateTargets(targets: ('node'|'relationship')[], excludeNodeMargin: boolean): void| Event | Signature |
|---|---|
onPan | (panning: { x: number; y: number }, event: MouseEvent) => void |
ClickInteraction
new ClickInteraction(nvl, options?: { selectOnClick?: boolean })| Event | Signature |
|---|---|
onNodeClick | (node: Node, hits: HitTargets, event: MouseEvent) => void |
onRelationshipClick | (rel: Relationship, hits: HitTargets, event: MouseEvent) => void |
onCanvasClick | (event: MouseEvent) => void |
onNodeDoubleClick | (node, hits, event) => void |
onRelationshipDoubleClick | (rel, hits, event) => void |
onCanvasDoubleClick | (event) => void |
onNodeRightClick | (node, hits, event) => void |
onRelationshipRightClick | (rel, hits, event) => void |
onCanvasRightClick | (event) => void |
HoverInteraction
new HoverInteraction(nvl, options?: { drawShadowOnHover?: boolean })| Event | Signature |
|---|---|
onHover | `(element: Node \ |
DragNodeInteraction
new DragNodeInteraction(nvl)| Event | Signature |
|---|---|
onDragStart | (nodes: Node[], event: MouseEvent) => void |
onDrag | (nodes: Node[], event: MouseEvent) => void |
onDragEnd | (nodes: Node[], event: MouseEvent) => void |
BoxSelectInteraction
new BoxSelectInteraction(nvl, options?: { selectOnRelease?: boolean })| Event | Signature |
|---|---|
onBoxStarted | (event: MouseEvent) => void |
onBoxSelect | (selection: { nodes: Node[]; rels: Relationship[] }, event: MouseEvent) => void |
LassoInteraction
new LassoInteraction(nvl, options?: { selectOnRelease?: boolean })| Event | Signature |
|---|---|
onLassoStarted | (event: MouseEvent) => void |
onLassoSelect | (selection: { nodes: Node[]; rels: Relationship[] }, event: MouseEvent) => void |
KeyboardInteraction
new KeyboardInteraction(nvl, options?: {
enterGraphKey?: string[]
navigateForwardKey?: string[]
navigateBackwardKey?:string[]
exitGraphKey?: string[]
contextMenuKey?: string[]
})
keyboard.getFocused(): Node | Relationship | undefined| Event | Signature |
|---|---|
onKeyDown | `(event: KeyboardEvent, focused?: Node \ |
onKeyUp | `(event: KeyboardEvent, focused?: Node \ |
onNodeFocus / onNodeBlur | (node: Node, event: KeyboardEvent) => void |
onRelationshipFocus / onRelationshipBlur | (rel: Relationship, event: KeyboardEvent) => void |
onCanvasFocus / onCanvasBlur | (event: FocusEvent) => void |
onContextMenu | `(element: Node \ |
---
React Components
<InteractiveNvlWrapper>
| Prop | Type | Notes |
|---|---|---|
nodes | Node[] | |
rels | Relationship[] | |
layout | Layout | |
layoutOptions | LayoutOptions | |
nvlOptions | NvlOptions | |
nvlCallbacks | ExternalCallbacks | |
positions | Node[] | |
zoom | number | |
pan | { x: number; y: number } | |
mouseEventCallbacks | MouseEventCallbacks | See below |
keyboardEventCallbacks | KeyboardEventCallbacks | See below |
interactionOptions | InteractionOptions | Defaults: { selectOnClick: false, drawShadowOnHover: true, selectOnRelease: false, excludeNodeMargin: true } |
onInitializationError | (error: unknown) => void | |
ref | Ref<NVL> | Underlying NVL instance |
(plus HTMLProps<HTMLDivElement>) |
<BasicNvlWrapper>
Same prop set EXCEPT no mouseEventCallbacks / keyboardEventCallbacks / interactionOptions. ref resolves to IncludeMethods<NVL> — exposes all NVL public methods.
<StaticPictureWrapper>
| Prop | Type | Default |
|---|---|---|
nodes | Node[] | — |
rels | Relationship[] | — |
nvlOptions | NvlOptions | — |
width | number | 500 |
height | number | 500 |
format | `'png' \ | 'svg'` |
Renders an ephemeral NVL, fits viewport, captures, destroys. Returns <img />.
MouseEventCallbacks
Union of all handler callbacks. Each value can be a function (with the signature above), true (enabled, no callback), or omitted / false (disabled):
onNodeClick, onRelationshipClick, onCanvasClick,
onNodeDoubleClick, onRelationshipDoubleClick, onCanvasDoubleClick,
onNodeRightClick, onRelationshipRightClick, onCanvasRightClick,
onHover,
onPan, onZoom, onZoomAndPan,
onDragStart, onDrag, onDragEnd,
onBoxStarted, onBoxSelect,
onLassoStarted, onLassoSelect,
onHoverNodeMargin, onDrawStarted, onDrawEndedKeyboardEventCallbacks
onKeyDown, onKeyUp,
onNodeFocus, onNodeBlur,
onRelationshipFocus, onRelationshipBlur,
onCanvasFocus, onCanvasBlur,
onContextMenuInteractionOptions
Merged union of every handler's options:
{ selectOnClick?, drawShadowOnHover?, selectOnRelease?, excludeNodeMargin?, controlledZoom?,
controlledPan?, enterGraphKey?, navigateForwardKey?, navigateBackwardKey?, exitGraphKey?,
contextMenuKey?, ghostGraphStyling? }NVL — Troubleshooting & Gotchas
Container renders nothing / 0×0
Cause: Parent <div> has no explicit height. NVL inherits 0 → graph invisible.
<!-- ❌ -->
<div id="viz"></div>
<!-- ✅ -->
<div id="viz" style="width: 100%; height: 600px;"></div>React: wrap <InteractiveNvlWrapper> or <BasicNvlWrapper> in a sized parent OR pass style={{ width, height }} directly (the wrappers forward HTMLProps<HTMLDivElement>).
---
Driver EagerResult rejected by NVL
Cause: Default executeQuery returns { records, summary, keys } — records are not Node/Relationship shapes.
// ❌
const res = await driver.executeQuery('MATCH (a)-[r]-(b) RETURN a,r,b LIMIT 25')
new NVL(container, res.records, []) // ids missing, shape wrong
// ✅
import { nvlResultTransformer } from '@neo4j-nvl/base'
const { nodes, relationships } = await driver.executeQuery(
'MATCH (a)-[r]-(b) RETURN a,r,b LIMIT 25', {},
{ database: 'neo4j', resultTransformer: nvlResultTransformer }
)
new NVL(container, nodes, relationships)For driver setup → neo4j-driver-javascript-skill.
---
Web Worker construction blocked
Cause: NVL's @neo4j-nvl/layout-workers spawns Web Workers for the force-directed and hierarchical layouts. Some environments block worker construction — strict CSP (worker-src 'none'), sandboxed iframes, custom runtimes, or older build tools that don't bundle workers correctly.
Fix: Disable workers — NVL has a synchronous fallback (slower for large graphs, identical output). Applies to any build tool (Vite, Webpack, Rollup, esbuild, Parcel, etc.):
const nvl = new NVL(container, nodes, rels, { disableWebWorkers: true })React:
<InteractiveNvlWrapper nvlOptions={{ disableWebWorkers: true }} ... />Most modern bundlers handle worker construction natively — try the default setup first, fall back to disableWebWorkers: true only when worker errors actually appear.
---
Renderer trade-off (Canvas vs WebGL)
| Renderer | Practical ceiling | Captions | Hit-test |
|---|---|---|---|
| Canvas (default) | ~1,000 nodes | Full styling | Pixel-perfect |
| WebGL | 100,000+ nodes | Bound by GPU max texture size | Approximate |
NVL's WebGL renderer targets WebGL2. WebGL1 still loads on the current release but support is on a deprecation path — assume WebGL2 in any new deployment.
// At construction
new NVL(container, nodes, rels, { renderer: 'webgl' })
// At runtime
nvl.setRenderer('webgl') // or 'canvas'Pick based on node count and label fidelity needs. Switching at runtime triggers a re-render.
---
WebGL captions/labels disappear
Cause: GPU max texture size exceeded — common on mobile / integrated GPUs (often 4096 px).
Fix: Either fall back to Canvas (setRenderer('canvas')), shrink captionSize on affected nodes, or shorten caption strings. Inspect via gl.getParameter(gl.MAX_TEXTURE_SIZE) if uncertain.
---
onWebGLContextLost fires
Cause: GPU dropped the context (tab backgrounded too long, driver crash, OS swap).
Fix: Implement onWebGLContextLost callback — call nvl.setRenderer('canvas') as recovery, or nvl.restart(options, true) to reinitialize while keeping positions:
const nvl = new NVL(container, nodes, rels,
{ renderer: 'webgl' },
{ onWebGLContextLost: () => nvl.restart({ renderer: 'canvas' }, true) }
)---
Telemetry — opting out
Cause: NVL ships Segment Analytics; runs by default.
Fix:
new NVL(container, nodes, rels, { disableTelemetry: true })Mandatory in regulated / air-gapped / customer-data-sensitive deployments.
---
Memory leak after route change (React)
Cause: Custom useRef + manual new NVL(...) not torn down on unmount.
Fix: Always call nvl.destroy() in cleanup:
useEffect(() => {
const nvl = new NVL(ref.current!, nodes, rels)
return () => nvl.destroy()
}, [])<InteractiveNvlWrapper> / <BasicNvlWrapper> handle this automatically — prefer them over manual refs.
For vanilla, destroy all interaction handlers BEFORE the NVL instance:
for (const h of handlers) h.destroy()
nvl.destroy()---
Layout never settles / isLayoutMoving stays true
Cause: Graph has competing forces or is unsolvable; or workers disabled and synchronous fallback is throttled.
Fix options:
- Pin anchor nodes:
nvl.pinNode(id)for known fixed positions - Cap iterations:
nvlOptions: { layoutTimeLimit: 3000 }(ms) - Switch layout:
nvl.setLayout('hierarchical')for DAG-like data - Reduce nodes (paginate query, increase
LIMIT)
---
Selection callback fires twice
Cause: selectOnClick toggled mid-render OR both base handler + wrapper handling clicks.
Fix: Set interactionOptions={{ selectOnClick: true }} once at mount and leave it; don't flip the value across renders. If using vanilla ClickInteraction, do not also register a manual container.addEventListener('click', ...).
---
Hit test misses near node edge
Cause: Default hitNodeMarginWidth: 0 requires pointer strictly inside the node circle.
Fix: Widen the hit margin:
const { nvlTargets } = nvl.getHits(evt, ['node', 'relationship'], { hitNodeMarginWidth: 8 })For pan-on-relationship sensitivity, set excludeNodeMargin: true in PanInteraction options.
---
Layout / hierarchy looks wrong
Cause: Default 'forceDirected' not suited for tree / pipeline / circular data.
Fix: Pick the right layout up front:
| Data shape | Layout |
|---|---|
| Generic network | 'forceDirected' |
| Tree / DAG | 'hierarchical' (set direction in layoutOptions) |
| Sorted ring | 'circular' (provide sortFunction) |
| Manual positioning | 'free' |
| Snapped grid | 'grid' |
| Force-directed via d3-force | 'd3Force' |
---
License pitfall
Cause: NVL is licensed under the Neo4j Visualization Library License — for use with Neo4j products only.
Implication: Cannot ship NVL inside an application that uses a non-Neo4j graph backend (Neptune, JanusGraph, ArangoDB, etc.) as its primary data store. Refer to the LICENSE.txt in @neo4j-nvl/base and confirm with legal before redistribution.
---
Image export returns blank PNG
Cause: Called saveToFile() before onLayoutDone fires — initial positions still settling.
Fix: Trigger export from the callback:
const nvl = new NVL(container, nodes, rels, {},
{ onLayoutDone: () => nvl.saveToFile({ filename: 'graph.png' }) })Or getImageDataUrl() for in-memory data URL.
---
restart() vs addAndUpdateElementsInGraph()
`restart()` rebuilds internal state; expensive; resets selection unless retainPositions=true is passed AND positions are preserved. Use for option changes (renderer, layout, styling).
`addAndUpdateElementsInGraph()` is the correct API for streaming data changes. Use for live updates.
// ❌ data update via restart — full rebuild every tick
nvl.restart()
// ✅ diff update
nvl.addAndUpdateElementsInGraph(newNodes, newRels)Related skills
FAQ
Is Neo4j Nvl Skill safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.