ApexTree 2.0 is out. One change explains the rest of it.

Collapse and expand used to be a re-render. ApexTree tore the tree down and rebuilt it, so every structural change was a cut rather than a transition. 2.0 replaces that with a spring motion core: a single animation ticker drives persistent per-node springs, and the render path reconciles the existing DOM instead of wiping it. Nodes travel to their new homes, connectors bend continuously with them, and a gesture can be interrupted mid-flight without a flicker.

That reconciler is what makes the rest of the release possible. Measured node sizing, expandable rich cards, semantic zoom, live data updates, radial layouts and the animated active path all sit on top of the same machinery, which is why they arrived together rather than one per release.

No public option or method was removed, and 60 new option fields plus 17 new graph methods are additive. The major version reflects changed defaults: motion is on, the expand/collapse control is redesigned, and reading node positions synchronously after render() now returns a different answer. Jump to migration notes if you are upgrading an existing chart.

Key takeaways

  • Spring motion core: positions, enter/exit, edges, the camera and the stagger are all springs, so velocity carries across a retarget and an interrupted gesture never snaps.
  • Live data: graph.updateData(data) diffs against the live tree; survivors interpolate, and collapse state, selection, focus and expanded cards survive the update.
  • Radial and dendrogram: direction: 'radial' plus layoutType: 'cluster', with curved dendrogram links and per-ring label thinning.
  • Node architecture: per-node sizes, autoNodeHeight measured from content, and cards that expand in place to reveal a detail section.
  • Navigation on large trees: focus mode, the animated active path, semantic zoom, batch expand/collapse verbs, lazy children, and a Cmd/Ctrl+K command palette.
  • Four visible default changes, each with a one-line way back to 1.15 behavior.

See it live

The tree below is the real ApexTree 2.0 the site ships. Every button runs a single reflow, so the whole affected set springs together in one wave rather than one render per node. Click any card to spotlight its lineage and flow the path back to the root.

Every button above runs one reflow, so the whole set of nodes springs together in a single wave. Click any card to spotlight its lineage; click it again or press Escape to clear.

What does the spring motion core actually change?

Five things animate, and they are all derived rather than scripted: node position, enter and exit, edge geometry, the camera (the viewBox is four springs), and the stagger.

The derived part matters more than the spring part. An edge is recomputed from its two endpoint springs every frame, which fixes lines-arriving-before-nodes structurally instead of with a timing offset: an edge has no length while both of its ends are still stacked on the root, and grows only as they separate. There is no keyframe to get out of sync.

const graph = new ApexTree(el, {
  direction: 'top',
  motion: { spring: 'crisp', stagger: 'wave' },
}).render(data)

motion.spring picks the integrator's stiffness and damping: 'crisp' (default) settles without visible overshoot, 'gentle' is softer for large reflows, 'snappy' is faster. motion.stagger: 'wave' (default) delays each node by its graph distance from the pivot, so a subtree unfolds outward instead of all at once; 'none' moves everything together.

Entering nodes grow in with a Safari-safe scale-in: a symmetric clip-path: inset() on the card plus opacity on the SVG decorations, because opacity on a foreignObject descendant triggers a WebKit paint collapse. Exiting nodes retract into their parent, clip fully out by halfway, and unmount on spring rest, with their edges morphing and fading on the same schedule instead of outliving them.

Grouped-leaf trees (groupLeafNodes: true) run through the same engine with no full-rebuild fallback. The side-bracket connector is a multi-point edge recomputed from the live parent and leaf springs, so it morphs rather than jumping to a new shape.

First renders grow out of the root

Previously a first render placed every node at its final position and revealed it in place, so nothing moved and the nodes read as popping in. Now every node seeds on the root and springs outward, staggered by depth, through the same reveal an expand uses.

This is the one change most likely to affect an existing test suite. See migration notes.

How do you animate a tree between two datasets?

graph.updateData(data) diffs the incoming tree against the live one and runs the same reconcile.

const graph = new ApexTree(el, { direction: 'top' }).render(data)

socket.on('org:changed', (next) => graph.updateData(next))
NodeBehavior
In both datasetsKeeps its DOM wrapper and springs to its new position
Only in the new dataEnters from its parent
Only in the old dataRetracts into its parent and unmounts

Because the wrappers survive, so does everything the browser hangs off them: keyboard focus, hover state, text selection. So does ApexTree's own per-node state, which means collapse state, selection, focus and expanded cards all carry across an update.

Stable ids are the contract. The diff keys on id, so ids regenerated per payload make every node look new: the old tree exits, a fresh one enters, and you have the redraw you were avoiding.

// stable: survives reordering, renaming and re-parenting
{ id: `emp-${employee.employeeId}`, name: employee.fullName }

// unstable: changes the moment anything is inserted or sorted
{ id: `node-${index}`, name: employee.fullName }

A node that keeps its id can be renamed, re-parented and re-titled across an update and it will still travel rather than blink.

The time_travel demo folds ten quarters out of an event log (hire, leave, move, title) so node ids stay stable by construction, then scrubs through them from a headcount chart. Folding forward from events rather than diffing two arbitrary trees is what keeps a re-parented person identifiable: the move event changes their parentId while their id stays put, so they animate to their new manager instead of being deleted and recreated. Full pattern in Live Data Updates.

Radial and dendrogram layouts

direction: 'radial' puts the root at the centre and each depth on a ring. It is a polar remap of the existing Reingold-Tilford result, so parent-centring and contiguous per-subtree wedges carry over, and the ring step auto-grows so crowded inner rings never collide.

new ApexTree(el, {
  direction: 'radial',
  layoutType: 'cluster',
  nodeWidth: 10,
  nodeHeight: 10,
  borderRadius: '50%',
  externalLabel: { enabled: true, fontSize: '10px', collisionStrategy: 'leaves' },
}).render(flareHierarchy)

layoutType: 'tree' | 'cluster' (default 'tree') decides where each rank sits. 'cluster' pins every leaf to the deepest rank for a true dendrogram, and it applies to cartesian directions too, where leaves line up on the bottom row instead of the outer ring.

Two details are what make a dense radial tree readable rather than just circular:

  • Radial edges are cubic dendrogram links curved around the centre rather than straight spokes, so sibling branches stay distinguishable where they bunch up near the root.
  • External labels fan out along their spoke and flip 180 degrees on the left half so they never render upside down. externalLabel.collisionStrategy ('none' | 'hide' | 'leaves') then thins labels per ring by arc length: 'hide' keeps a maximal, evenly spaced subset, and 'leaves' additionally drops inner-node labels.

Radial buttons are placed by ray-rectangle intersection along each node's growth direction. Previously radial reused the 'top' case, which parked every button at bottom centre: correct only near the bottom of the dial, and sitting on the incoming parent edge for nodes near the top.

Details in Radial and Dendrogram Layouts.

Node sizing and cards that expand

Fixed node sizes were the load-bearing limit on everything card-shaped: a card sized for the longest job title wastes space on every other node. Two layers lift it, and both feed the one source of truth the layout, camera, edges and motion already read.

Per-node sizing. Any node's options may carry nodeWidth / nodeHeight, overriding the global value. Edges anchor to each card's own half-size, so mixed-size siblings separate correctly.

Measured heights. autoNodeHeight measures each card's content in a reused offscreen box and sets its height, with minHeight, maxHeight and extraHeight. Precedence is explicit, then measured, then global. It returns nothing without a DOM, so SSR and jsdom stay deterministic. Cartesian only: grouped-leaf and radial stay uniform.

On top of that, a card can expand in place to reveal a detail section, which is distinct from expanding a node's children:

const graph = new ApexTree(el, {
  autoNodeHeight: { enabled: true },
  cardExpansion: { clickToExpand: true },
}).render(data)

graph.toggleCard('alice')

OrgNodeData gained tags (shown in the summary) plus stats, progress, actions and details, revealed only when expanded. A custom nodeTemplate receives an expanded flag so it can render its own detail section and mark a toggle with data-apextree-card-toggle.

Expansion reflows through the existing reconciler, so siblings spring apart rather than jumping. It needs autoNodeHeight enabled to actually grow the card. See Node Sizing and Expandable Cards.

Four additions, each aimed at a tree too big to read at once.

Focus mode. graph.focus(id) dims everything outside a node's lineage and visible subtree and springs the camera to frame it; Escape, a re-click or clearFocus() restores. The dim is paint-order rather than opacity on cards, so Safari stays intact, and it survives a collapse while focused. focus takes { clickToFocus: false, dimOpacity: 0.7 }. It returns false when the node is not currently rendered, so check the result rather than assuming the call landed.

The animated active path. graph.setActivePath(ids) flows a marching dash along the root-to-node lineage, with clearActivePath() and getActivePath(). edgeFlow configures color, width, speed, dash and gap length, direction ('toChild' or 'toParent'), and followFocus to wire it to focus mode. Plain SVG plus injected keyframes, and it respects reduced motion by highlighting without animating.

Semantic zoom. semanticZoom re-tiers node content by how wide a node appears on screen, with thresholds compactBelow: 90 and dotBelow: 42. The important property is that node geometry is fixed across tiers: the layout is computed once and crossing a threshold only swaps what renders inside each node's box, so nothing moves and the camera is untouched. That is what makes it safe on dynamic data of unknown shape, and it means there is nothing per-tier to configure. Custom templates receive a lod flag.

A command palette. enableCommandPalette adds a Cmd/Ctrl-K overlay for jumping to a node by fuzzy label match, or running expand all, collapse all, or fit to screen. A plain DOM overlay, keyboard-navigable, dismissed with Escape, and every string is localizable.

Two general canvas fixes shipped alongside semantic zoom:

  • Wheel zooms and drag pans were silently swallowed whenever the camera spring was mid-flight, because the spring rewrites the viewBox every frame. Both gestures now release the spring.
  • graph.zoom(factor) steps could be refused for no visible reason, because the pan-zoom accumulator drifts from the live viewBox after any programmatic camera move. zoom() now re-bases on the live viewBox first, so a step is always multiplicative on the real scale: zoom(1.2) is 20 percent in.

Batch verbs and lazy children

Five batch verbs replace loops over ids, and each runs as a single reflow so the whole set springs together in one wave: expandAll(), collapseAll(), expandToDepth(n), expandSubtree(id), collapseSubtree(id).

Lazy loading closes the other half of the large-tree problem. Mark a node hasChildren: true with no children, supply loadChildren(ctx), and expanding it shows an SVG spinner with aria-busy while the promise settles, then splices the subtree in through the reconciler.

new ApexTree(el, {
  loadChildren: async ({ id }) => {
    const res = await fetch(`/api/nodes/${id}/children`)
    return res.json()
  },
})

Return an empty array for a node that turned out to have none and its expand affordance is dropped. A rejected promise leaves the node collapsed so the user can retry, which means swallowing an error and returning [] is the wrong move: that marks the node as a leaf.

nodeWrapper(ctx) rounds out the extensibility story. It stamps your own classes and data-* attributes on each node's wrapper <g> without replacing its nodeTemplate content, refuses to overwrite the attributes ApexTree uses for identity, and runs outside the foreignObject so it is free of the Safari CSS constraints that apply to templates. That is where a context menu, a drag handle or a framework boundary belongs.

What to read before upgrading

Four changes are visible without opting in.

1. Node positions read synchronously after render(). A first render seeds nodes on the root and springs them outward, so data-x / data-y and a foreignObject's x / y return the seed immediately after render(). This already applied to expand(); the two paths are now consistent. If you measure layout from the DOM, use enableAnimation: false for that instance, or wait for the springs to settle.

2. The collapse count moved inside the button. The separate badge element is gone; the count renders inside the button, which widens into a pill. The collapseBadge* options and --apex-tree-badge-* variables still work and now style that pill, with collapseBadgeFontSize capped at 70 percent of the button size. If you targeted the badge's own element in CSS or tests, retarget the button. If you compensated for the badge's extra 27.5px of footprint in your spacing, remove that compensation.

3. The expand/collapse button looks different. The old glyphs were annuli painted over the button's own bordered circle at the same diameter, so the icon ring and the border sat flush and read as one heavy rim that blobbed at real size. They are now plain stroked arms. expandCollapseButtonSize defaults to 15 where the size was hardcoded to 14, expandCollapseButtonBorderColor moved from #BCBCBC to #E4E7EC, and hover moved off hardcoded Bootstrap blue onto borderColorHover. Two new opt-in options: expandCollapseButtonIconColor themes the glyph, which previously rendered black and disappeared on the dark theme's button, and expandCollapseButtonHaloColor draws an opaque ring so the button punches through the card border and the incoming edge.

4. Collapse and expand re-fit the camera with a zoom cap. 1.15 fitted the viewBox tightly to whatever remained, so collapsing to two nodes could balloon them to fill the canvas. maxZoomNodeSpan: 8 caps that. It never zooms out past the full tree and never shrinks the chart below 1:1, so a small chart keeps its tight fit instead of being scaled down on every collapse.

ChangeRestore 1.15 behavior
Motion on first render and reflowenableAnimation: false
Collapse zoom capmaxZoomNodeSpan: 0
Button size and borderexpandCollapseButtonSize: 14, expandCollapseButtonBorderColor: '#BCBCBC'
Collapse count inside the buttonnot configurable

Every other new option is off or neutral by default and needs no attention on upgrade: motion, focus, semanticZoom, autoNodeHeight, cardExpansion, edgeFlow, layoutType, nodeWrapper, loadChildren, enableCommandPalette, the countBadge* family and the radial direction.

The tree also honours reduced motion: add apextree-reduced-motion to the container, or wire it to matchMedia, and animations are skipped with elements shown at their final state.

Full detail in Migrating to ApexTree 2.0.

How do I upgrade?

npm install apextree@latest

Or bump the version on your CDN link. The framework wrappers were released alongside the core:

WrapperVersion
react-apextree2.1.0
vue-apextree2.1.0
ngx-apextree1.2.0

All three expose the expanded surface (updateData, the batch verbs, focus, setActivePath, toggleCard, zoom, centerOnNode) plus getGraph() for anything else. In all three, a change to the data prop or input is now reconciled into the live tree rather than rebuilding it, while an options change still rebuilds because options are read at construction. Keep your options object referentially stable so an unrelated re-render does not remount the chart.

Where to go next

Frequently asked questions

What is new in ApexTree 2.0?

The keystone is the spring motion core. Collapse and expand used to tear the tree down and rebuild it, so every structural change was a cut. 2.0 drives persistent per-node springs from one animation ticker and reconciles the DOM instead of wiping it, so nodes travel to their new positions, connectors bend continuously with them, and an interrupted gesture never snaps. Everything else in the release sits on that reconciler: radial and dendrogram layouts, per-node and measured node sizing, cards that expand in place, focus mode with an animated active path, semantic zoom, live data updates via updateData(), lazy children, batch expand and collapse verbs, a Cmd/Ctrl+K command palette, and always-on count badges. That is 60 new option fields and 17 new graph methods, all additive.

Are there breaking changes in ApexTree 2.0?

No public option or method was removed, and every new option defaults to inert. The major version reflects four changed defaults. First, a first render now seeds nodes on the root and springs them outward, so reading data-x/data-y or a foreignObject's x/y immediately after render() returns the seed, not the settled layout; set enableAnimation: false if you measure layout from the DOM. Second, a collapsed node's hidden-descendant count moved inside the expand/collapse button, which widens into a pill, so the separate badge element is gone. Third, the expand/collapse button was redesigned: stroked glyphs instead of annuli, expandCollapseButtonSize defaults to 15 where it was hardcoded to 14, and expandCollapseButtonBorderColor moved from #BCBCBC to #E4E7EC. Fourth, maxZoomNodeSpan defaults to 8, capping how far the camera zooms in when it re-fits after a collapse.

How do I restore the ApexTree 1.15 behavior after upgrading?

enableAnimation: false renders and reflows with no motion, exactly as 1.15 did. maxZoomNodeSpan: 0 restores the old tight camera fit, or enableExpandCollapseZoom: false keeps the viewBox fixed entirely. expandCollapseButtonSize: 14 with expandCollapseButtonBorderColor: '#BCBCBC' pins the old button size and border, though there is no flag to bring back the annulus glyph. The collapse count moving inside the button is not configurable. Every other addition is off or neutral by default.

How do I animate an ApexTree between two datasets?

Call graph.updateData(nextData) on the graph returned by render(). It diffs the incoming tree against the live one on node id: nodes in both keep their DOM wrapper and spring from where they are to where the new layout puts them, nodes only in the new data enter from their parent, and nodes only in the old data retract and exit. Because the wrappers survive, so do keyboard focus, hover, text selection, collapse state, selection, focus mode and expanded cards. Stable ids are the contract: if ids are regenerated per payload every node looks new and you get the redraw you were trying to avoid.

Does ApexTree 2.0 support radial layouts and dendrograms?

Yes. direction: 'radial' puts the root at the centre with each depth on its own ring, as a polar remap of the same Reingold-Tilford result the cartesian directions use, so parent-centring and contiguous per-subtree wedges carry over. layoutType: 'cluster' pins every leaf to the deepest rank for a true dendrogram, and it works in cartesian directions too, where leaves line up on the bottom row. Radial edges are cubic dendrogram links curved around the centre, external labels fan out along their spoke and flip on the left half, and externalLabel.collisionStrategy thins colliding labels per ring so a dense dendrogram stays legible.

Do the React, Vue and Angular wrappers support the new API?

Yes. react-apextree 2.1.0, vue-apextree 2.1.0 and ngx-apextree 1.2.0 all expose the same expanded surface: updateData, expandAll, collapseAll, expandToDepth, focus, clearFocus, setActivePath, clearActivePath, toggleCard, zoom and centerOnNode, plus getGraph() for anything else. In all three, a change to the data prop or input is now reconciled into the live tree rather than rebuilding it, while an options change still rebuilds because options are read at construction.