@bravobit/org-chart
v1.0.2
Published
Renderer-driven organization/ownership chart library with an injectable DOM adapter. Zero runtime dependencies, fully tree-shakable.
Downloads
70
Maintainers
Readme
@bravobit/org-chart
Dependency-free TypeScript library for visualizing ownership and corporate structures — KvK / OpenCorporates / UBO style. Inverted layout (root at the bottom, owners above) or classic top-down, HTML nodes rendered exactly the way you want, and a mathematical guarantee that nodes never overlap and lines never cross them.
- 🪶 Zero runtime dependencies, fully tree-shakable (
sideEffects: false) — the pure layout engine bundles at ~7 KB - 🧩 Bring your own nodes: every node is your HTML, sized and rendered by your renderer
- 🎨 CSS-first styling: one stylesheet, tuned with CSS variables — nothing is injected at runtime
- 🖱️ Interactive: pan, wheel zoom, pinch zoom, hover highlighting, node & edge events
- ⌨️ Keyboard accessible: tabbable nodes, arrow-key navigation, Enter/Space activation
- 🖼️ SVG export: render any dataset to a standalone SVG string — server-side too
- 🌐 Runs anywhere: all DOM access goes through an injectable adapter (browser, jsdom, headless)
- 🔒 Strictly typed, generic over your node data
Commercial license required. This is proprietary software: using it requires a paid license from Bravobit B.V., and modification is not permitted. See License.
Contents — Installation · Quick start · Data model · Renderers · Typed node data · Styling · Viewport & interaction · Keyboard & accessibility · Events · Hover highlighting · Managing data · Options · DOM adapter · Headless layout · SVG export · API reference · Guarantees · License
Installation
npm install @bravobit/org-chartQuick start
import { OrgChart, browserDomAdapter } from '@bravobit/org-chart';
import '@bravobit/org-chart/styles.css';
// 1. A renderer per node.type: you control size and markup.
const renderers = {
company: {
size: () => ({ width: 220, height: 64 }),
render: (node) => `<div class="card">${node.data.label}</div>`,
},
person: {
size: () => ({ width: 180, height: 56 }),
render: (node) => `<div class="card card--person">${node.data.label}</div>`,
},
};
// 2. Create the chart — renderers and a DOM adapter are always explicit.
const chart = new OrgChart(document.getElementById('chart')!, {
renderers,
dom: browserDomAdapter(),
});
// 3. Set data and render. Edges point from the owned entity UP to its owner.
chart
.setData({
nodes: [
{ id: 'bv', type: 'company', data: { label: 'Meridiaan B.V.' } },
{ id: 'holding', type: 'company', data: { label: 'Holding B.V.' } },
{ id: 'ubo', type: 'person', data: { label: 'A. de Groot' } },
],
edges: [
{ from: 'bv', to: 'holding', direction: 'up', label: '100%', arrow: 'from' },
{ from: 'holding', to: 'ubo', direction: 'up', label: '100%', arrow: 'from' },
],
})
.on('nodeClick', (node) => console.log('clicked', node.id))
.render({ fit: true });That's the whole integration: a container element, a stylesheet, renderers, and data. The root (bv) sits at the bottom; ownership builds upward.
Data model
Nodes
interface OrgChartNode<TData = Record<string, unknown>> {
id: string; // unique
type: string; // selects the renderer — required, there is no default
data?: TData; // everything your renderer displays
floating?: boolean; // render detached from the tree (parked at the side)
fx?: number; // fixed position for a floating node
fy?: number;
}Nodes carry no dimensions — sizes come from the renderer (size()), so data and presentation never disagree.
Floating nodes are parked beside the chart (or pinned at fx/fy) — but they are full citizens: edges may claim owners or side branches on them, and that subtree is laid out and parked with them as one unit. The park starts level with the root and stacks as a column by default; set parkDirection: 'horizontal' to line floating nodes up in a row instead.
Edges
interface OrgChartEdge {
from: string;
to: string;
direction?: 'up' | 'left' | 'right' | 'down'; // default: 'up'
label?: string; // e.g. '40%' or 'affiliated'
arrow?: 'none' | 'to' | 'from' | 'both';
dashed?: boolean; // default depends on edge kind
}The direction describes where the target ends up relative to the source:
| direction | Meaning | Placement |
|---|---|---|
| up | ownership/hierarchy | target above the source, in the tree |
| right / left | affiliated branch | target beside the source, carrying its own full subtree |
| down | subsidiary branch | target below the source |
Declaration order matters: the first structural claim on a node wins. Any additional edge to an already-claimed node becomes a cross link — drawn as a dashed curve, without structural influence on the layout.
Orientation
By default the hierarchy grows upward (root at the bottom — the ownership/UBO reading). Set orientation: 'down' for a classic org chart with the root at the top:
new OrgChart(el, { renderers, dom, orientation: 'down' });
chart.setOptions({ orientation: 'up' }).render({ fit: true }); // switch at runtimeDirection names are semantic, not visual: they are defined in the canonical 'up' space, so direction: 'up' always means toward the owner/parent — in 'down' orientation that relation simply renders below. The same dataset therefore works unchanged in both orientations; orientation is pure presentation. Internally 'down' is an exact y-mirror of the computed geometry, so all layout guarantees carry over by construction.
Renderers
A renderer answers two questions per node.type: how big is this node? and what does it look like?
interface NodeRenderer<TData> {
size(node: OrgChartNode<TData>): { width: number; height: number };
render(node: PlacedNode<TData>, dom: DomAdapter): DomElement | string;
}render may return an HTML string or an element. Sizes may depend on content:
const company: NodeRenderer = {
// wider card for longer names
size: (node) => ({ width: 140 + String(node.data?.label ?? '').length * 6, height: 64 }),
render: (node) => {
const el = document.createElement('div');
el.className = 'card card--company';
el.textContent = String(node.data.label);
el.addEventListener('pointerup', () => console.log('inner interactivity works too'));
return el;
},
};Every node.type in your data must have a renderer; a missing one throws a clear error. Nodes are placed as <foreignObject> elements, so your HTML/CSS works unmodified inside the SVG.
Stay inside the box: a
foreignObjectclips its content at exactly the size your renderer reports — browsers applyoverflow: hiddenper the SVG spec, and hit-testing outside the bounds is unreliable even where painting works. Design badges, buttons, and focus effects within the reported width/height (the library's own focus ring draws inward for the same reason). If something must stick out, makesize()include that headroom.
Security note: a renderer returning a string is inserted as-is (
innerHTML). Whennode.datacontains untrusted input (imported registries, user uploads), build an element and set text viatextContent/dom.setTextinstead of interpolating into a string.
Typed node data
All node-facing APIs are generic over the shape of node.data:
interface CompanyData { label: string; kvk?: string }
const companyRenderer: NodeRenderer<CompanyData> = {
size: () => ({ width: 220, height: 64 }),
render: (node) => `<div>${node.data.label}</div>`, // node.data is CompanyData
};
const chart = new OrgChart<CompanyData>(el, {
renderers: { company: companyRenderer },
dom: browserDomAdapter(),
});
chart.on('nodeClick', (node) => console.log(node.data.kvk)); // typedUntyped usage keeps working — TData defaults to Record<string, unknown>.
Styling
Import the structural stylesheet once (canvas behavior, edge lines, node container — nothing more):
import '@bravobit/org-chart/styles.css';Node appearance is 100% yours, via your renderers and CSS. Edge and fade styling is tuned with CSS variables on the container:
.ogc {
--ogc-edge: #55637a; /* edge stroke color */
--ogc-edge-label: #1d2c44; /* label text color */
--ogc-edge-label-bg: #ffffff; /* label halo (keeps text readable over lines) */
--ogc-fade-opacity: 0.25; /* opacity of faded elements on hover */
--ogc-fade-duration: 150ms; /* fade transition duration */
--ogc-focus: #4c6fff; /* keyboard focus ring color */
}Useful class hooks:
| Class | On |
|---|---|
| .ogc | the container you passed in |
| .ogc-node | every node group (.ogc-node--<type> per type, .ogc-node--floating for floating nodes) |
| .ogc-node:hover | hover styling for your cards |
| .ogc-edge, .ogc-edge--dashed | edge paths |
| .ogc-edge-label | edge labels |
| .ogc-edge-hit | invisible wide hover/click target over every edge |
| .ogc--dim | elements faded by hover highlighting |
| .ogc--panning | on the svg while dragging |
Viewport & interaction
Out of the box: drag to pan, wheel to zoom, two-pointer pinch zoom on touch, and a configurable double-click action.
chart.fit(); // scale & center the whole chart (optionally: fit(padding))
chart.zoomIn(); // step zoom around the center
chart.zoomOut();
chart.render(); // re-render, PRESERVES current pan/zoom
chart.render({ fit: true }); // re-render and fitrender() never moves your viewport uninvited — incremental updates (addNode, removeNode, setData) stay exactly where the user left the chart. Fit is always explicit.
new OrgChart(el, { renderers, dom, dblClick: 'zoomIn' }); // 'fit' (default) | 'zoomIn' | 'none'By default the wheel always zooms, which captures page scroll above the chart. For charts embedded in a scrolling page, zoom: { wheel: 'ctrlZoom' } gives Figma/Maps-style behavior: the page scrolls normally, Ctrl/Cmd+wheel (and trackpad pinch) zooms. 'none' disables wheel zoom entirely.
Keyboard & accessibility
The chart works without a pointer, out of the box:
- The svg carries
role="group"and an accessible name (ariaLabeloption). - Every node is a tab stop; the focus ring is styled via
--ogc-focus(drawn with:focus-visible, so it only appears for keyboard users). - Arrow keys move focus to the spatially nearest node in that direction.
- Enter / Space activates the focused node —
nodeClickfires with the node's on-screen center asclientX/clientY, so positioned popovers keep working. +/-/0zoom in, zoom out, and fit.- Focusing a node reports through
nodeHoverand applies the hover highlight, so tooltips and fading work for keyboard users too.
Arrow-key focus movement uses the optional DomAdapter.focus operation; custom adapters without it lose only that feature.
Events
Subscribe with on, unsubscribe with off; both chain.
| Event | Signature | Fires |
|---|---|---|
| nodeClick | (node, ev) | pointer press + release on a node without dragging, or Enter/Space on a focused node |
| nodeHover | (node \| null, ev) | hovered (or keyboard-focused) node changed; null when leaving all nodes |
| edgeClick | (edge, ev) | click on an edge line or label |
| edgeHover | (edge \| null, ev) | hovered edge changed; null when leaving all edges |
Edges are thin, so every edge gets an invisible ~14px-wide hit path (.ogc-edge-hit); lines and labels are comfortable hover/click targets. Edge handlers receive the RoutedEdge (from/to/label/kind), e.g. for percentage tooltips or relation detail panels. ev is structurally typed (ChartPointerEvent: clientX, clientY, target) — no casting needed for tooltip positioning.
const onHover = (node, ev) => (node ? tooltip.show(node, ev) : tooltip.hide());
chart.on('nodeHover', onHover);
// later, e.g. on component unmount:
chart.off('nodeHover', onHover);nodeHover fires even when the visual hover effect is disabled, so tooltips work independently of the fade.
Hover highlighting
Hovering a node emphasizes the relevant part of the structure: the hovered node and everything "connected" stays fully visible, everything else — including edges that don't connect two visible nodes — fades out smoothly.
// default: on. Disable the fade (events keep firing):
new OrgChart(el, { renderers, dom, hover: { enabled: false } });What counts as "connected" is yours to define. Two resolvers ship with the library — highlightNeighbors (default: the hovered node plus its direct neighbors) and highlightSubtree (the hovered node plus its entire structural subtree) — or you write your own:
import { highlightSubtree } from '@bravobit/org-chart';
// ready-made subtree variant:
new OrgChart(el, { renderers, dom: browserDomAdapter(), highlight: highlightSubtree });
// or fully custom, e.g. only UBOs stay visible:
new OrgChart(el, {
renderers,
dom: browserDomAdapter(),
highlight: (node, layout) =>
[...layout.nodes.values()].filter((n) => n.type === 'person' || n.id === node.id).map((n) => n.id),
});The resolver receives the hovered PlacedNode and the current LayoutResult, and returns the node ids to keep. The fade itself is pure CSS (--ogc-fade-opacity, --ogc-fade-duration).
Managing data
chart.setData(data); // replace everything
chart.getData(); // current data (as set)Ownership: the chart takes ownership of the object passed to
setData—addNode/removeNodemutate its arrays in place, andgetData()returns the same live object. Pass a copy (structuredClone(data)) if the original must stay untouched, e.g. when it lives in framework state.
chart.addNode(node, edge?); // add a node, optionally with its connecting edge
chart.removeNode('id'); // remove node + its entire subtree + all their edges
chart.removeNode('id', { cascade: false }); // remove only the node itself
chart.getLayout(); // computed positions, routed edges, bounding box
chart.destroy(); // remove the svg, listeners, and container classMutations don't render by themselves — call render() when you're ready:
chart
.addNode({ id: 'x1', type: 'person', data: { label: 'New UBO' } },
{ from: 'holding', to: 'x1', direction: 'up', label: '50%' })
.render(); // keeps the current viewportOptions
All options are optional and deep-merged over the defaults; chart.setOptions(patch) applies the same merge at runtime (call render() to apply):
new OrgChart(el, {
renderers, dom,
gap: { sibling: 40, level: 80, side: 56, forest: 96, park: 120, parkStack: 24 },
edge: { arrow: 'none', attachDashed: true, crossDashed: true, elbowRadius: 8 },
zoom: { min: 0.1, max: 3, step: 1.2, wheelSpeed: 0.0015, wheel: 'zoom' },
fitPadding: 48,
fitMaxZoom: 1.25,
hover: { enabled: true },
dblClick: 'fit',
ariaLabel: 'Organization chart',
});| Option | Default | Description |
|---|---|---|
| orientation | 'up' | which way the hierarchy grows (see Orientation) |
| parkDirection | 'vertical' | how floating nodes stack in the park: a column toward the leaves ('vertical') or a row away from the chart ('horizontal'); both start level with the root |
| gap.sibling | 40 | horizontal gap between sibling subtrees |
| gap.level | 80 | vertical gap between hierarchy levels |
| gap.side | 56 | gap between a node and its left/right/down branch |
| gap.forest | 96 | gap between separate trees (multiple roots) |
| gap.park / gap.parkStack | 120 / 24 | gap between chart and park / between parked floating nodes |
| edge.arrow | 'none' | default arrowheads ('to' \| 'from' \| 'both') |
| edge.attachDashed / edge.crossDashed | true | dash side branches / cross links |
| edge.elbowRadius | 8 | corner radius of orthogonal edges |
| zoom.min / zoom.max / zoom.step / zoom.wheelSpeed | 0.1 / 3 / 1.2 / 0.0015 | zoom behavior |
| zoom.wheel | 'zoom' | wheel behavior: 'zoom' \| 'ctrlZoom' \| 'none' (see Viewport) |
| fitPadding | 48 | padding used by fit() |
| fitMaxZoom | 1.25 | upper bound on the scale fit() may choose |
| hover.enabled | true | fade unrelated nodes on hover |
| dblClick | 'fit' | double-click action ('fit' \| 'zoomIn' \| 'none') |
| ariaLabel | 'Organization chart' | accessible name of the chart |
| measureLabel | – | optional pure (label) => { width, height } for exact edge-label metrics when you restyle the label font |
DOM adapter
The library never touches browser globals. Every DOM operation goes through a DomAdapter — a flat set of operations over opaque element handles, inspired by Angular's Renderer2 — which you provide explicitly:
import { OrgChart, browserDomAdapter, type DomAdapter } from '@bravobit/org-chart';
// In the browser:
new OrgChart(el, { renderers, dom: browserDomAdapter() });
// Against another document (iframe, jsdom):
new OrgChart(host, { renderers, dom: browserDomAdapter(iframe.contentDocument) });
// Or fully custom (tests, instrumentation, other runtimes):
const myAdapter: DomAdapter = { createElement, createSvgElement, appendChild, /* … */ };
new OrgChart(host, { renderers, dom: myAdapter });All operations are required except focus, which powers arrow-key focus movement; adapters without it lose only that feature.
Because the adapter is a required, explicit dependency, the browser implementation never sneaks into bundles that don't use it.
Headless layout
The layout engine is pure — no DOM required. Use it server-side or in tests via computeLayout:
import { computeLayout, DEFAULTS } from '@bravobit/org-chart';
const layout = computeLayout(
{ nodes, edges },
(node) => ({ width: 200, height: 60 }), // size resolver
DEFAULTS,
);
layout.nodes; // Map<string, PlacedNode> — final x/y/w/h per node
layout.edges; // RoutedEdge[] — ready-made SVG path per edge
layout.bbox; // overall bounding box
layout.roots; // detected root idsImporting only computeLayout tree-shakes the entire render/DOM layer away (~7 KB minified).
SVG export
renderToSvgString renders a dataset to a standalone SVG string — same layout engine, same markup as the live chart, but pure: no DOM, no browser. Use it server-side (reports, thumbnails, e-mail) or client-side (download button, printing):
import { renderToSvgString } from '@bravobit/org-chart';
const svg = renderToSvgString(data, {
renderers,
padding: 32, // whitespace around the chart (default 24)
css: '.card { background: #fff }', // embedded so the file is self-contained
gap: { side: 80 }, // all layout options apply
});
// e.g. offer as a download:
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));Notes:
- The structural edge/label styles are embedded with
var(--…, fallback), so the file renders standalone and picks up your.ogctheme variables when inlined in a page. - Your node HTML is included as-is; pass its CSS via
css(or use inline styles in the renderer) to make the file self-contained. - Renderers must create elements via the provided
domadapter (or return HTML strings) —document.createElementthrows a clear error here, since there is no document. - Output is deterministic: same input → identical string (snapshot-test friendly). Interactive artifacts (hit paths,
tabindex) are omitted; the root carriesrole="img".
API reference
new OrgChart<TData>(container, config)
| config | Required | Description |
|---|---|---|
| renderers | ✅ | Record<string, NodeRenderer<TData>> — one per node.type |
| dom | ✅ | DomAdapter — e.g. browserDomAdapter() |
| highlight | – | HighlightResolver<TData> — hover keep-set, default highlightNeighbors |
| …options | – | everything from Options |
Methods
| Method | Returns | Description |
|---|---|---|
| setData(data) / getData() | this / data | replace / read the dataset |
| setOptions(patch) | this | deep-merge options at runtime (e.g. orientation); apply with render() |
| addNode(node, edge?) | this | add a node (+ optional connecting edge) |
| removeNode(id, {cascade?}) | this | remove node; cascade (default) removes its subtree |
| render({fit?}) | this | (re)render; preserves pan/zoom unless fit: true |
| fit(padding?) | this | scale & center the chart in the container |
| zoomIn() / zoomOut() | this | step zoom around the center |
| on(event, fn) / off(event, fn) | this | subscribe / unsubscribe (nodeClick, nodeHover, edgeClick, edgeHover) |
| getLayout() | LayoutResult<TData> \| null | positions, routed edges, bbox |
| destroy() | void | remove svg, unbind listeners, clean the container |
Standalone exports
| Export | Description |
|---|---|
| browserDomAdapter(doc?) | DomAdapter backed by the real DOM |
| computeLayout(data, sizeOf, opts) | pure layout engine (headless) |
| renderToSvgString(data, config) | standalone SVG export (headless) |
| highlightNeighbors / highlightSubtree | ready-made hover resolvers |
| DEFAULTS | the default options (e.g. as opts for computeLayout) |
All public types are exported: OrgChartData, OrgChartNode, OrgChartEdge, PlacedNode, RoutedEdge, LayoutResult, NodeRenderer, RendererMap, DomAdapter, DomElement, HighlightResolver, OrgChartEvents, OrgChartOptions, ChartPointerEvent, SvgExportConfig, and more.
Layout guarantees
Placement uses recursive bounding boxes: placed nodes mathematically never overlap. Edges never cross nodes either — child edges run through a "bus" strip that is empty by construction, down branches enter via a reserved corridor, and side branches that cannot be reached in a straight line (2nd+ branches on one side, or branches whose root carries sub-branches toward the anchor) are reached via routing lanes below the boxes in the way. Overlapping edge labels in dense areas are automatically nudged apart.
Malformed input degrades gracefully: cyclic claims (e.g. a→b as a branch and b→a as its owner) are detected across the whole claim graph — up-parents and side claims combined — and the cycle-closing edge is demoted to a dashed cross link, so every component keeps a root and the overlap guarantee holds.
A crossing detector in the test suite verifies all of this — with exact segment/rect geometry, not sampling — across ~900 fuzzed structures (0 overlaps, 0 crossings), including 500 fully random graphs with arbitrary cycles.
Known limitations:
- Box spacing is conservative in exchange for the overlap guarantee.
- Cross links may run across nodes (deliberately low priority).
- No position animation on re-layout.
Development
Requires Node ≥ 22. Tests use the built-in node:test runner (zero extra dependencies) and run against the built dist/ output, so they verify the artifact consumers actually get.
npm install
npm run build # tsup → dist/ (ESM + CJS + d.ts + org-chart.css)
npm test # build + typecheck + lint + all suites
npm run lint # eslint (typescript-eslint, type-checked rules)
npm run test:unit # suites only, against the existing dist/ (fast loop)
npm run test:watch # re-run on change
npm run test:coverage # per-TS-file coverage via source maps, with thresholds
npm run check:package # publint + arethetypeswrong (exports/types health)
# focus on one suite or test by name:
node --test --test-name-pattern="cycles" "tests/*.test.mjs"
# interactive demo (consumes the built dist/):
npm run build && npx serve . # → open http://localhost:3000/demo/Releases follow Keep a Changelog: user-visible changes are added to the Unreleased section of CHANGELOG.md as part of the change itself, and move into a versioned, dated section when publishing.
License
Proprietary — see LICENSE. Installing this package does not grant a right to use it: usage requires a paid commercial license agreement with Bravobit B.V., and modifying the software or creating derivative works is not permitted. A 14-day non-production evaluation is allowed. For licensing inquiries, open an issue at bravobit/bravobit-org-chart.
