Guide

JavaScript Org Chart

Every directory API returns flat rows with a manager id. Every tree renderer wants nested children. This is that conversion, in both directions, and what breaks in between.

Org Chart with MetricsOpen in new tab

Built with ApexTree

An org chart draws a reporting hierarchy: one root, each person under exactly one manager, expanding and collapsing as you explore. The same component draws any single-parent hierarchy, which is most of what people call a tree: a file system, a category taxonomy, a decision tree, a chart of accounts.

ApexTree is the one in this family. It renders SVG with zero dependencies, and it takes nested data:

npm install apextree

<div id="tree" style="width: 800px; height: 500px"></div>

<script type="module">
  import ApexTree from 'apextree'

  const tree = new ApexTree(document.getElementById('tree'), {
    width: 800,
    height: 500,
    direction: 'top',
  })

  const graph = tree.render({
    id: 'ceo',
    name: 'Ada Whitfield',
    children: [
      {
        id: 'eng',
        name: 'Bo Nakamura',
        children: [
          { id: 'eng-1', name: 'Cleo Ferrer', children: [] },
          { id: 'eng-2', name: 'Dev Raman', children: [] },
        ],
      },
      {
        id: 'sales',
        name: 'Esi Boateng',
        children: [{ id: 'sales-1', name: 'Farid Haddad', children: [] }],
      },
    ],
  })
</script>

Leaf nodes take children: [], not an omitted key. render() returns a graph handle, and that handle is what you call collapse, expand, expandAll, collapseAll, expandToDepth and updateData on.

Your API returns flat rows. ApexTree wants nested.

This is the whole job, and it is the reason most first attempts stall. Every HR system, directory API and SQL table returns one row per person with a pointer to their manager:

[
  { "id": "3", "name": "Cleo Ferrer", "managerId": "2" },
  { "id": "1", "name": "Ada Whitfield", "managerId": null },
  { "id": "4", "name": "Dev Raman", "managerId": "2" },
  { "id": "2", "name": "Bo Nakamura", "managerId": "1" }
]

ApexTree has no flat-input mode, so you convert. Two passes, because one pass only works if parents happen to appear before their children, and the payload above is deliberately out of order the way a real one is:

function toNested(rows, { idKey = 'id', parentKey = 'managerId', labelKey = 'name' } = {}) {
  const byId = new Map()

  // Pass 1: every row becomes a node, so a child can find a parent that has
  // not been visited yet.
  for (const row of rows) {
    byId.set(String(row[idKey]), {
      id: String(row[idKey]),
      name: row[labelKey],
      children: [],
    })
  }

  // Pass 2: attach each node to its parent. Anything without a resolvable
  // parent is a root.
  const roots = []
  for (const row of rows) {
    const node = byId.get(String(row[idKey]))
    const parent = row[parentKey] == null ? null : byId.get(String(row[parentKey]))
    if (parent) parent.children.push(node)
    else roots.push(node)
  }

  return roots
}

const graph = tree.render(toNested(rows)[0])

String() on both sides of the lookup is not decoration. Numeric ids from a database and string ids from JSON will not match with Map.get, and the symptom is a chart with one node and no error.

The orphan problem, which is the part that bites

toNested returns an array of roots, and render() takes a single node. That mismatch is where data quietly disappears.

Add one row whose manager is not in the payload, which is what a paginated or permission-filtered API gives you:

{ id: '5', name: 'Unassigned Contractor', managerId: '999' }

Now toNested(rows) returns two roots: the real CEO and that contractor. Rendering roots[0] draws four nodes and silently drops the contractor's entire subtree. Measured on apextree 2.1.0.

So check the count rather than indexing blindly:

const roots = toNested(rows)
if (roots.length !== 1) {
  console.warn(`Expected one root, got ${roots.length}:`, roots.map((r) => r.name))
}

If multiple roots are legitimate for your data, either render a synthetic parent above them, or render one chart per root.

Going back the other way

Saving an edit means flattening again, which is a depth-first walk:

function toFlat(root, parentId = null, out = []) {
  out.push({ id: root.id, name: root.name, managerId: parentId })
  for (const child of root.children) toFlat(child, root.id, out)
  return out
}

A reorganisation is then a diff of two flat lists on managerId, which is a much easier thing to send to an API than a diff of two nested trees.

Does expand and collapse state survive a data update?

Yes, and this is worth knowing because the obvious assumption is that it does not. Calling graph.updateData(data) keeps whatever is collapsed collapsed.

Measured on apextree 2.1.0: with a six-node tree fully expanded, collapsing one manager hides exactly that manager's two reports; calling updateData() with the same data leaves those two hidden rather than restoring them.

What is not provided is a way to read that state back. There is no getCollapsed(), so if you want the tree to reopen the way the user left it after a page reload, track the ids yourself:

const collapsed = new Set(JSON.parse(localStorage.getItem('org-collapsed') ?? '[]'))

const graph = tree.render(data)
for (const id of collapsed) graph.collapse(id)

function onToggle(id, isCollapsed) {
  isCollapsed ? collapsed.add(id) : collapsed.delete(id)
  localStorage.setItem('org-collapsed', JSON.stringify([...collapsed]))
}

graph.expandToDepth(n) is the cheaper option when you only want a sensible starting view: expandToDepth(1) shows the root and its direct reports.

A note for TypeScript users

NestedNode declares data as required, so this fails to compile even though it renders perfectly at runtime:

const node: NestedNode = { id: 'x', name: 'X', children: [] }
// error TS2741: Property 'data' is missing in type
// '{ id: string; name: string; children: never[]; }'
// but required in type 'NestedNode<undefined>'

data is where your own payload rides along, available in callbacks and node templates. Type the node with it and the required field stops being friction:

interface Person { email: string; title: string }

const node: NestedNode<Person> = {
  id: 'x',
  name: 'X',
  data: { email: 'x@example.com', title: 'Engineer' },
  children: [],
}

When is an org chart the wrong shape?

What your data doesReach for
One parent per node, one rootAn org chart. This is the case.
Nodes with several parents (matrix reporting, dotted lines)Not a tree. A tree layout has to pick one parent and will misrepresent the rest.
Quantities flowing between stages, merging and splittingA Sankey diagram. Sankey nodes take many inputs; tree nodes take one.
Hierarchy where the leaf sizes are the messageA treemap. A tree shows structure; a treemap shows proportion.
A few dozen rows in a table, hierarchy incidentalA data grid with tree data. Keeps the columns readable and still indents the hierarchy.
Thousands of nodes at onceNeither, as drawn. Use expandToDepth, lazy children or semantic zoom so the layout only ever holds what is on screen.

The matrix-reporting row is the one to take seriously. If people in your organisation genuinely report to two managers, an org chart is a lossy picture of it, and choosing which edge to draw is a decision you should make deliberately rather than let a layout make for you.

What ApexTree ships

ApexTree is a commercial library, included from the Pro plan upward. It is not part of the Community tier, so unlike ApexCharts.js it is not covered by the under-$2M waiver. Nothing here is open source: source published on GitHub is not the same thing as an open licence. Every feature below renders in full without a licence key, watermarked, so the whole page is testable against your own data first. The pricing page carries the plan matrix.

Included
Four directions plus radial and dendrogram layoutsYes
Node templates and rich cardsYes
Expand, collapse, expand-to-depth, batch togglingYes
Lazy-loaded childrenYes
Search, command palette, focus modeYes
Semantic zoom, pan, fit-to-screenYes
Selection and active-path highlightingYes
Count badges and external labelsYes
Live data updates and time travelYes
Theming, CSS custom properties, dark themeYes
Localization and RTLYes
Accessibility supportYes
SVG export (exportToSvg)Yes

See the pieces running

Reference documentation

Frequently Asked Questions

How do I convert flat parent-id data into an org chart?

Two passes over the rows. First build a map from id to node so a child can find a parent that has not been visited yet, then attach each node to its parent and collect anything with no resolvable parent as a root. A single pass only works when parents happen to appear before their children, which a real API does not guarantee.

Why is my org chart missing people?

Most likely the conversion produced more than one root and you rendered the first. A row whose manager is absent from the payload, which is what pagination or permission filtering gives you, becomes its own root, and ApexTree renders a single root node, so that whole subtree is dropped without an error. Check the root count before rendering.

Does ApexTree accept flat data with parent ids?

No. `render()` takes one nested node whose children nest recursively, so the flattening is yours to do. Leaf nodes need `children: []` rather than an omitted key.

Does expand and collapse state survive a data update?

Yes. Calling `updateData()` keeps collapsed branches collapsed, verified on apextree 2.1.0. There is no getter for that state though, so persisting it across a page reload means tracking the collapsed ids yourself and re-applying them with `collapse(id)` after render.

Can an org chart show matrix or dotted-line reporting?

Not faithfully. A tree layout gives each node exactly one parent, so when someone reports to two managers the layout has to pick one edge and the other relationship is not drawn. If dual reporting is central to what you are showing, a tree is a lossy picture of it and the choice of which edge to keep should be deliberate.

Related

Start with ApexTree

Zero dependencies, and free for organizations under $2M in annual revenue.

Get started