Relationship graphs with clean, logical lines.

Plain HTML, CSS & JavaScript: few crossings, ports, orthogonal routing that never goes through nodes, lines that never sit on top of each other, groups and full layout control.

Open the full demo →

Getting started

Install the package and render a graph into any element with a size:

npm install @kazant/flexgraph
import { createGraph } from "@kazant/flexgraph";
import "@kazant/flexgraph/style.css";

const view = createGraph(document.getElementById("graph"), data, { direction: "LR" });

API

Data

{
  nodes:  [{ id, label, width?, height?, group?, ports?: [{ id, side: "top|right|bottom|left", offset? }],
             constraints?: { pinned, x, y, rank }, details?, expandable?, expanded? }],
  edges:  [{ id, source, target, sourcePort?, targetPort?, type?, label?, markerStart?, markerEnd? }],
  groups: [{ id, label }],
  constraints: [{ type: "leftOf", a, b }, { type: "sameRank", nodes: [...] }]
}

Options

OptionDefault
direction"TB"TB, LR, BT, RL
edgeRouting"orthogonal"orthogonal, straight, curved
nodeSpacing / rankSpacing / edgeSpacing40 / 80 / 10px
cornerRadius, lineHops6, trueRounded bends; arcs where lines must cross
animate, pinOnDragtrue, true
hoverHighlight"chain"chain: hovering lights up the whole path from start to end; neighbors: only direct connections
highlightSelectiontrueClicking a node or edge selects it and keeps its highlight until you click the background, press Esc or select something else
history, historyLimittrue, 100Undo / redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y, buttons in the controls) for moves, pins, relayout, updateGraph, expand / collapse and direction changes
multiSelecttrueCtrl/Cmd/Shift + click adds or removes items, Shift + drag draws a selection box, Ctrl+A selects all; dragging a selected node moves the whole selection
renderNode(node, el)Custom node content (HTML string or element)
renderDetails(node, el)Content of an expanded node. Every node gets a "Details" strip at the bottom; or give single nodes details (HTML). expandText / collapseText set the strip text
explorefalseExplore mode for huge hierarchies: only nodes without a parent are shown; clicking a node opens its children. Only the open part is laid out
exploreUnfocused, exploreLimit"dim", 25Explore mode: nodes off the open path are dim, blur or hide; children shown per node before a "+N more" node
bundleFanOut5When this many edges leave one side of a node they share one trunk (tree style); 0 turns it off
onNodeContextMenu(node, event, { previous, next }), onEdgeContextMenu, onGroupContextMenu, onBackgroundContextMenuRight-click callbacks for opening your own menu (FlexGraph has none built in). The browser menu is suppressed only for targets you handle
onNodeClick(node, event, { previous, next }), onEdgeClickClick callbacks. previous / next are the nodes directly before and after the clicked node
workerURL of flexgraph.worker.js to lay out large graphs off the main thread

Methods

view.relayout()            // full layout, respects pinned nodes
view.updateGraph(data)     // incremental: existing nodes stay where they are
view.exportState()         // { nodes: { id: { x, y, pinned } }, edges: { id: [[x, y], …] }, transform }
view.importState(state)
view.pin(id) / view.unpin(id) / view.fit() / view.zoomBy(1.2) / view.select(id)
view.fitFlow(id)           // zoom so the whole flow through a node fills the view (default: the selection)
view.fitNodes(["a", "b"])  // zoom so these nodes and the lines between them fill the view
view.on("nodeclick", ({ node, previous, next }) => …)
view.on("contextmenu", ({ kind, node, edge, group, event }) => …)   // right-click
view.getConnections(id)                 // { previous, next, incoming, outgoing }
view.getConnections(id, { chain: true }) // everything upstream / downstream
view.select(["a", "b"]) / view.select("c", { add: true }) / view.selectAll() / view.clearSelection()
view.getSelectedNodes() / view.getSelectedEdges() / view.getSelectedItems()
view.on("selectionchange", ({ nodes, edges }) => …)
view.undo() / view.redo() / view.canUndo() / view.getHistory() / view.batch("label", fn)
view.on("history", ({ data, canUndo, canRedo }) => …)   // after undo, data may be an earlier object
view.expand(id) / view.collapse(id) / view.toggleExpand(id) / view.isExpanded(id)
view.setExplorePath(["a", "b"]) / view.getExplorePath() / view.exploreBack()   // explore mode
view.on("expand", ({ id, expanded }) => …)
view.on("explore", ({ path }) => …)

Saving a layout

Users can drag nodes until the lines run the way they want. exportState() returns plain JSON with the node positions, pins, zoom and the exact edge lines. Store it anywhere (your database, localStorage, a file) and pass it to importState() to recreate the same picture. If a node has changed size since it was saved (other fonts, edited labels), the lines on that node are routed fresh instead.

// save
const state = view.exportState();
await fetch("/api/diagrams/42/layout", { method: "PUT", body: JSON.stringify(state) });

// restore
const view = createGraph(el, data);
await view.ready;
await view.importState(await (await fetch("/api/diagrams/42/layout")).json());

// or autosave after every drag
view.on("change", (state) => save(state));

How it's built

FlexGraph is written from scratch in plain JavaScript (ES modules, ES2020). It is not a fork or wrapper of any other library.

Dependencies

PackageUsed forShipped to your users?
NoneRuntime: the library imports nothing—
esbuildBuilding the minified bundles (dev dependency)No

Bundle size: about 91 kB minified (about 33 kB gzipped) for the main ESM file, plus an optional 40 kB Web Worker file. ESM and UMD builds, TypeScript definitions and a default stylesheet are included.

Techniques

Browser features used

SVG for edges and markers, HTML elements for nodes, CSS custom properties for theming, Pointer Events for drag, pan and pinch, requestAnimationFrame for animation, and optionally a Web Worker for large graphs. The layout engine itself uses no DOM, so it also runs in Node.js and in workers.

Development needs Node.js 20 or newer; tests use the built-in node --test runner.

Maintenance and risk

FlexGraph is an independent library. If you build on it, you should know what happens if development slows down or stops, and how to protect yourself.

What keeps working

The code you have installed keeps working as it is. There are no runtime dependencies that can break, disappear or be compromised, and the library only uses long-established, standardized browser features (SVG, CSS custom properties, Pointer Events, Web Workers). Your graph data and saved layouts are plain JSON in a documented format.

What you would lose

RiskLikelihoodWhat it meansHow to reduce it
No bug fixesMediumLayout or routing problems in unusual graphs would stay unfixed.Test with your own real graphs before you rely on it; pin the version that works for you.
No security fixesLowThe library never runs or loads code and makes no network requests. The main risk is HTML you pass in yourself (the html field and renderNode strings) — labels are always inserted as plain text.Escape user-provided content in custom node HTML, or return DOM elements from renderNode.
Browser changesLowThe features used are stable web standards that browsers keep compatible.Nothing needed in practice.
Tooling driftLow–mediumNew TypeScript, bundler or framework versions might need small adjustments (types, import paths).The package is plain ES modules + UMD with no build step required; adjustments can be made locally.
No support or new featuresMediumQuestions go unanswered and requested features never arrive.Keep the integration thin (one wrapper component), so you can extend or replace it.

How to protect yourself

Support the project

FlexGraph is free and open source under the MIT license: use it in personal and commercial projects, modify it and ship it, with no fees. It is built and maintained by one person.

If it saves you or your company time, a donation through GitHub Sponsors is much appreciated and helps keep it maintained. Bug reports, ideas and pull requests on GitHub are just as welcome.

FAQ

Can I use it with React / Vue / Svelte / Angular?

Yes. FlexGraph has no framework dependencies: create the graph when the component is mounted and call view.destroy() when it is removed. In React that is a useEffect, in Vue onMounted / onUnmounted, in Svelte onMount.

How do I use it in Angular?

FlexGraph works with zoneless Angular (the default in new projects): create the graph once the element has rendered, update it from an effect when the input changes, and destroy it with the component. Click callbacks can set signals directly; that is enough to update the view. Add the stylesheet to "styles" in angular.json: "node_modules/@kazant/flexgraph/style.css".

import { Component, ElementRef, DestroyRef, afterNextRender, effect, inject, input, signal, viewChild } from "@angular/core";
import { createGraph, type GraphData, type GraphView } from "@kazant/flexgraph";

@Component({
  selector: "app-graph",
  template: `
    <div #host style="height: 600px"></div>
    @if (selected(); as s) { <p>Selected: {{ s }}</p> }
  `
})
export class GraphComponent {
  data = input.required<GraphData>();
  host = viewChild.required<ElementRef<HTMLDivElement>>("host");
  selected = signal<string | null>(null);
  private view?: GraphView;

  constructor() {
    afterNextRender(() => {
      this.view = createGraph(this.host().nativeElement, this.data(), {
        direction: "LR",
        onNodeClick: (node) => this.selected.set(node.label ?? node.id)   // a signal update re-renders
      });
    });
    effect(() => {
      const data = this.data();
      this.view?.updateGraph(data);   // existing nodes stay in place
    });
    inject(DestroyRef).onDestroy(() => this.view?.destroy());
  }
}

Still on zone.js? The same component works; optionally create the graph inside NgZone.runOutsideAngular so dragging and zooming don't trigger change detection. For very large graphs, copy flexgraph.worker.js to your assets and pass its URL as the worker option.