Klad: a canvas-based tree engine for large trees
Klad is a tree engine for the web. It lays out and draws the tree on a canvas inside a Web Worker. Framework components are mounted only for the nodes that are on screen and zoomed in far enough to read.
I wrote it because DOM-based org chart libraries slow down on large trees. Each node is an element and each connector is another one. A five hundred person chart leaves the browser with several thousand elements to lay out and style, and most of them are off screen at any given moment.
Lists solve this with virtualisation, which works because a list is one-dimensional. A tree sits on a two-dimensional plane with connectors between nodes, so the same method does not apply directly. Most libraries do not attempt it.
Basic usage
data is a flat array, and the only option without a default.
import { createKlad } from '@klad/core'
const chart = createKlad(document.getElementById('chart')!, {
data: [
{ id: 'ceo', name: 'Jamie Fox', title: 'CEO' },
{ id: 'cto', parentId: 'ceo', name: 'Amy Chen', title: 'CTO' },
{ id: 'cfo', parentId: 'ceo', name: 'Priya Rao', title: 'CFO' },
],
})
chart.on('nodeClick', ({ id, item }) => console.log('clicked', id, item))
Each item is { id, parentId?, ...your own fields }. There is no nested children structure to build. If a parentId matches no record, that node is treated as a root and a warning event is emitted instead of an exception. Data usually comes from a database that does not guarantee referential integrity, so I made it work this way on purpose.
nodeSize
Layout runs inside a Web Worker, and a worker has no DOM. There is no element to mount and no getBoundingClientRect() to call, so you declare node sizes yourself:
nodeSize: Size | ((item: NodeData) => Size) // Size = { w: number; h: number }
When a card's height does change, api.refresh() re-reads every size and lays out again, keeping expand/collapse state, camera position and highlight.
If the worker cannot start
It logs the reason with console.warn and falls back to the main thread. This happens in three cases: a CSP that blocks worker scripts, a browser without OffscreenCanvas, or a canvas whose 2D context has already been taken. Options, events and the API stay the same. You can force this behaviour yourself with worker: false.
Where components come in
Using only canvas would mean giving up your design system. So the canvas is not on its own; it is the cheapest tier of a level-of-detail system.
export type LodTier = 'block' | 'label' | 'full'
export const DEFAULT_LOD: LodThresholds = { text: 0.25, overlay: 0.6 }
block— below 0.25 zoom, only boxes and connectors are drawn. Text is not readable at that scale anyway.label— one truncated line of text per node.full— at 0.6 and above the full card is drawn and the DOM overlay activates.
A node becomes a real element only in the last tier. Your Vue slot, React render prop or your own DOM is mounted here, and those elements are pooled and reused as you move around.
flowchart LR
Data["{ id, parentId }"] --> Worker[Web Worker: layout]
Worker --> Canvas[Canvas draw]
Canvas --> Zoom{"zoom >= 0.6"}
Zoom -->|no| Done[Canvas only]
Zoom -->|yes| Overlay[Mount components for visible nodes]
One small note: if the zoom value is invalid, such as NaN, every >= comparison fails and the system falls back to block, the cheapest tier. That is the behaviour you want when something goes wrong.
One data shape, four layouts
The difference between an org chart and a file explorer is not the data, it is how you draw it. So layout is an option, and the same flat array feeds all four.
createKlad(host, { data, layout: 'sunburst' })
| Layout | For | Grows |
|---|---|---|
tidy |
Org charts, decision trees, anything read top-down. The default. | Wide, fast |
file |
File explorers, outlines, long nested lists. | Down only |
radial |
Wide, shallow trees: a root with many short branches. | Outward |
sunburst |
Proportions: how much of a level a branch takes up. | Outward, bounded |
orientation applies to tidy only: 'tb' | 'bt' | 'lr' | 'rl'. There is also rtl to reverse sibling order.
file stands apart on scale: it is the only layout whose width does not grow with the tree. A thousand siblings mean a thousand rows, not a thousand columns. For large, mostly-collapsed structures it is the shape that stays usable.
Trees you cannot load up front
Most charts need the whole tree before they can draw anything. That rules out the cases where the shape helps most: a file system too large to enumerate, a taxonomy behind an API, an organisation of a hundred thousand people.
const chart = createKlad(host, {
data: roots,
mayHaveChildren: (item) => Number(item.childCount) > 0,
loadChildren: (item) => fetch(`/api/children/${item.id}`).then((r) => r.json()),
})
Why two options instead of one? The chart only knows the data it was given. A node with no children in data cannot be told apart from a real leaf; neither has anything to click. Something has to say there is more before anything can be fetched.
mayHaveChildren provides that. You usually answer from a count field, and that count can be wrong. A node that turns out to have no children once loadChildren returns simply becomes a leaf, and nothing else breaks.
Very wide levels
A manager with four hundred reports, or a folder with ten thousand files. That level becomes unreadable, and zooming does not fix it.
const chart = createKlad(host, {
data,
maxChildren: 8,
pinChildren: (item) => watching.has(String(item.id)),
})
There are two options here as well. On its own maxChildren is just a truncation; it shows whichever nodes come first. Working through five levels of a hundred, the eight that matter to you are unlikely to be the first eight.
pinChildren decides which ones are shown: the records you are working on, a search result, the current selection. Pinned nodes are not counted against the limit, they come before it; pin ten with a limit of eight and you see ten. Ordering follows the data in either case, so a pinned node is not moved to the front — it keeps its place among its siblings.
Accessibility
A canvas is invisible to screen readers. It cannot take focus, carries no roles, and has no readable content. Drawing the chart on a canvas and stopping there produces an interface a screen reader user cannot use at all.
So a hidden but real DOM tree sits alongside the canvas. Each node has a row with role="treeitem", and aria-expanded and aria-level are kept up to date. Two points matter:
- Rows are hidden by clipping rather than
display: none.display: nonewould also remove them from the accessibility tree, which would leave no reason to keep them. - They use
content-visibility: auto, so this hidden DOM does not become expensive on large trees.
| Key | Effect |
|---|---|
↑ / ↓ |
Previous / next row |
→ |
Expands a collapsed node, or moves to the first child if already expanded |
← |
Collapses an expanded node, or moves to the parent if collapsed or a leaf |
Enter / Space |
Expands or collapses the focused row |
Home / End |
First / last row |
m |
Picks up the focused node; a second m drops it wherever focus is |
The camera pans to the focused node as focus moves, so the canvas follows the screen reader.
Editing
With dragAndDrop: true you can drag a node to another parent or between two siblings. If the node is part of a selection, the whole selection moves with it. Collapsed branches open when the pointer rests on them, moves that would create a cycle are refused, and Escape puts the node back. The same operation is available from the keyboard with m.
Every move is emitted as an event before it happens; to disallow one, call preventDefault(). You do not have to describe your business rules to the chart.
Current state
Klad is at 1.8.0 and is published as ESM only.
| Package | For |
|---|---|
@klad/core |
The framework-agnostic API. One function: createKlad. |
@klad/vue |
Vue 3: a <Klad> component with a #node scoped slot, plus useKlad(). |
@klad/react |
React: <Klad> with a render prop and a ref handle. |
@klad/engine |
Layout, viewport maths, spatial index, renderer, worker protocol. No DOM. Only needed if you are writing a new binding. |
npm install @klad/core # framework-agnostic
npm install @klad/vue # Vue 3 (>=3.5 <4)
npm install @klad/react # React (>=18)
The packages build on each other, so installing one is enough. You do not need @klad/core separately for the Vue adapter.
Requirements: Worker, OffscreenCanvas, ResizeObserver and Canvas2D. All are available in current browsers.
There are 442 tests across the engine, core, vue and react packages, and a good number of them run in a real browser rather than a simulated DOM. The playground has four orientations, RTL, variable node sizes, nine card treatments and a 20,000-node stress test.
There are two licence options: AGPL v3 or later by default, and a commercial licence for anyone who needs to use it in a closed-source product or a hosted service without the AGPL's source obligation.
- Documentation, API reference and a playground where you can try the options live: klad.ozdemir.be
- Source: github.com/n1crack/klad
If you try it and run into a problem, let me know.