ApexSankey 1.11 is out, and it is a large release: motion, interaction, projections, theming and extensibility, all layered on top of the flow model that was already there.

It is also fully additive and non-breaking. The 1.10.0 constructor and options keep working unchanged, and an existing diagram renders as it did. Internally the library was refactored into a clean Model, Layout, Renderer, Motion, Interaction pipeline on a shared spring engine, with no public API change.

Key takeaways

  • Animated updates: update(data) springs to a new layout when the topology is unchanged, and morphs through it when the topology differs.
  • One model, three projections: the layered Sankey, an alluvial diagram from categorical records, and a chord diagram for dense many-to-many relationships.
  • Layout: vertical orientation, and circular or cyclic flows routed as legible dashed back-edges.
  • Theming: five built-in themes, registerTheme for brand presets, and a nodePalette option.
  • Interaction: click-to-focus isolation, draggable nodes with touch and pen support, and optional particle flow.
  • Extensibility: a plugin API and typed event bus, three built-in plugins, and ApexSankey.compare() for a before/after split-view with a structural diff.

See it live

The diagram below is the real ApexSankey 1.11. The year switch calls update() on the live instance: coal leaves the mix and nuclear arrives, so that transition takes the grow-in morph path rather than a value tween. The projection, orientation and theme switches recreate the instance, because those are construction-time options.

Showing 2024. The year switch calls update() on the live instance: coal leaves and nuclear arrives, so that transition takes the grow-in morph path rather than a value tween. Orientation is a Sankey-only option.

How does an animated update work?

Two paths, chosen from how much actually changed.

const sankey = new ApexSankey(el, options)
sankey.render({ nodes, edges: edges2024, options: sankey.options })

// later, on the same instance
sankey.update({ nodes, edges: edges2025, options: sankey.options })
ChangeAnimation
Same topology, different values or positionsSpring relayout. Nodes and ribbons spring to their new places
Topology differsGrow-in morph. Entering flows unfurl out of their source node, survivors slide, removed flows retract and dissolve

Either way the diagram at rest is identical to what render() would have produced. The animation is presentation, not state, which is what makes the instant-redraw fallback safe: update() redraws immediately when animation.enabled is false or the user's system requests reduced motion, checked per call.

The diff keys on node id and on the (source, target, type) triple for flows, so identity is the contract here too. Ids derived from a mutable label (slugify(node.title)) turn a retitle into a delete-and-recreate. Give each flow a stable type for the same reason: two flows between the same pair with different type values are distinct.

The rendered event fires after the initial render and after each update() settles, which is the hook for anything that needs final geometry:

sankey.on('rendered', () => enableExportButton())

Details in Data Updates and Morphing.

Three projections, one model

The same { nodes, edges } renders three ways. This is the part of the release with the most reach, because it means choosing a projection is a config change rather than a different library.

The layered Sankey

The default, unchanged.

Alluvial

An alluvial diagram shows how a population redistributes across categories from one dimension to the next: plan tier in 2019 versus 2022, department last quarter versus this one, survey answer before versus after. The picture is a Sankey, but the input is not nodes and edges. It is a table of subjects and their category at each step, which is what buildAlluvialData converts.

const data = ApexSankey.buildAlluvialData({
  dimensions: ['2019', '2022'],
  records: [
    { values: { '2019': 'Free', '2022': 'Pro' } },
    { values: { '2019': 'Free', '2022': 'Free' } },
    { values: { '2019': 'Pro',  '2022': 'Team' } },
  ],
})

const sankey = new ApexSankey(el, { axisTitles: ['2019', '2022'] })
sankey.render({ ...data, options: sankey.options })

Each record takes an optional value weight, defaulting to 1, so band widths are headcounts unless you weight them by revenue or hours. Aggregate first if your source is already grouped: one record per distinct path with value set to that path's count is equivalent to one record per subject, and far cheaper.

A record with no entry for a dimension drops that adjacency rather than inventing a category, which is the correct behavior for a subject that did not exist yet or had already left. If you want joiners and leavers visible as flows, model them with a category of their own.

The builder takes any number of dimensions, and categories keep a consistent color across every axis, so a cohort is followable left to right by color alone. See Alluvial Diagrams.

Chord

A layered Sankey reads well when flow moves in one direction through stages. It falls apart when everything connects to everything: ranks stop being meaningful and the ribbons cross into noise. type: 'chord' draws the same model as a ring instead.

new ApexSankey(el, { type: 'chord', arcCornerRadius: 8 }).render(data)
Use a Sankey whenUse a chord when
Flow moves through stages in one directionEverything connects to everything
Ranks are meaningful (source, grid, consumer)There are no natural ranks
You want to read volume at each stageYou want to read pairwise relationships

Each node's arc length is the total of everything entering and leaving it, and both directions between a pair are supported as separate edges. arcCornerRadius (default 6) rounds the outer corners of each arc while the inner edge, where ribbons meet, stays flush so no gap opens between an arc and the flows attached to it.

Because a chord is a projection rather than a separate chart, theming, tooltips, interaction, events, accessibility and animated updates all apply unchanged. Only orientation and axisTitles are Sankey-specific. See Chord Diagrams.

orientation: 'vertical' puts ranks in rows and flows top to bottom. It is usually the better choice when category labels are long (rows give each label the full canvas width rather than making it compete for column width), when the container is narrow, or when the metaphor is descent: funnels, waterfalls, drilldowns.

Circular links are the more interesting change. A strict Sankey is acyclic, but real systems are not: recycling returns material to production, a support queue reopens a ticket, an economy feeds output back as input. ApexSankey now routes those flows rather than rejecting them, drawing an edge that points back to an earlier rank as a dashed back-edge routed around the diagram body.

edges: [
  { source: 'raw',     target: 'product', value: 100, type: 'flow' },
  { source: 'product', target: 'use',     value: 100, type: 'flow' },
  { source: 'use',     target: 'recycle', value: 40,  type: 'flow' },
  { source: 'recycle', target: 'raw',     value: 35,  type: 'loop' },
]

No option is needed; a cycle is detected from the topology. The dashed styling is deliberate: a back-edge is not the same kind of statement as a forward flow, and drawing it identically would imply a rank ordering that does not exist.

Path highlighting, click-to-focus and the pathTrace plugin are all cycle-guarded, so following a flow around a loop terminates. See Orientation and Circular Links.

Theming

theme seeds a coordinated set of visual defaults in one option, so a diagram does not need eight colors set by hand to look deliberate. Five built-ins: light, dark, midnight, mint, sunset.

Options you set explicitly still win, which makes a theme a baseline rather than a straitjacket:

new ApexSankey(el, {
  theme: 'sunset',    // seeds palette, canvas, label color
  edgeOpacity: 0.6,   // but this specific value wins
})

For a palette reused across an app, register it once by name instead of copying it into every chart:

ApexSankey.registerTheme('acme', {
  nodePalette: ['#ff5a5f', '#087f8c', '#5d2e8c'],
  fontColor: '#1a1a1a',
  canvasStyle: 'background: #faf7f2; box-sizing: border-box;',
})

new ApexSankey(el, { theme: 'acme' }).render(data)

If all you want is different node colors, skip themes and set nodePalette directly. See Themes.

Interaction

Click to focus and isolate. Hover highlighting is transient. Clicking a node or a flow now pins its full upstream and downstream path and dims the rest, so a path stays isolated while you read it. Cycle-guarded, and it composes with onNodeClick and the node:click event.

Draggable nodes. draggableNodes lets a reader reposition nodes with mouse, touch or pen, with connected flows following live. Useful when the automatic layout orders a rank in a way that fights the story, or when someone wants to untangle a specific crossing. A manual position holds until the next render() or update() recomputes the layout, so it is a viewing aid rather than persisted state.

Particle flow. particleFlow animates particles along each ribbon, with density proportional to the ribbon's value, so direction and relative volume are readable without labels. It is purely decorative and skipped entirely under reduced motion, which is exactly why it can be dropped safely: nothing a reader needs is conveyed by particles alone. It earns its place when direction is genuinely ambiguous (cyclic links, a chord with flows both ways), when the subject is throughput, or when the diagram is on display. It is worth skipping on a dense analytical view someone reads for minutes.

Plugins and the event bus

Behavior that is not every diagram's business now lives in plugins rather than options, and the bus those plugins are built on is public.

const off = sankey.on('node:click', ({ id }) => showDetail(id))

sankey.use({
  name: 'click-logger',
  install: ({ on }) => on('node:click', ({ id }) => console.log(id)),
})

Events: node:click, node:mouseenter, node:mouseleave, the three edge: equivalents, plus rendered and destroyed. The map is typed, so a handler's argument is narrowed by the event name. on returns an unsubscribe function, which is usually all the teardown a plugin needs to return.

Three plugins ship built in, as named exports and on ApexSankey.plugins.*:

PluginWhat it does
pathTraceSends a pulse along the flows connected to a picked node, cascading outward by depth
timePlaybackPlays through ordered frames with a play/pause control and a scrubber
drillDownCollapses groups of nodes into super-nodes that expand on interaction

drillDown grows entering nodes out of the super-node and shrinks leaving ones into it, so the change reads as a drilldown rather than a cross-fade. Its pure transform is exposed as ApexSankey.collapseGroups(data, groups, collapsed), which is how you seed a first render that is already collapsed.

destroy() runs every plugin's teardown, emits destroyed, drops all handlers, then releases the chart context. It is idempotent. See Plugins and Events.

Comparing two states instead of animating between them

An animation shows that something changed. It is poor at showing exactly what. When the difference is the point, ApexSankey.compare() renders both states side by side with a structural diff:

const cmp = ApexSankey.compare(el, {
  before: { nodes, edges: edges2024, title: '2024' },
  after:  { nodes, edges: edges2025, title: '2025' },
})

Every flow lands in one of four states, and each panel outlines the ones it is responsible for: removed flows marked on the left, added on the right, changed on both.

StateMeaningDefault outline
addedOnly in aftergreen
removedOnly in beforered
changedIn both, different valueamber
unchangedIn both, same valueno outline

Hovering a node or flow highlights its twin in the other panel. Both panels are ordinary ApexSankey instances, so events and plugins reach them, and the computed diff is available without touching the DOM:

const { edges, addedNodes } = cmp.diff
const grew = edges.filter((e) => e.status === 'changed' && e.afterValue > e.beforeValue)

Built on the public surface only (two ordinary instances plus their node:* and edge:* events), so it stays the same one engine as everything else. See Comparison Split-view.

How do I upgrade?

npm install apexsankey@latest

Or bump the version on your CDN link. Nothing to change: the release is additive, and every 1.10.0 option and call still works.

One note if you were reaching past the public API: the internal renderEdge method is no longer part of the surface.

Also worth knowing, from 1.10.0: license keys issued from 27 July 2026 onward carry an ECDSA P-256 signature checked in the browser, so a key whose payload has been edited no longer validates. Unsigned keys issued before that date keep working until 31 July 2027. See Setting the License.

Where to go next

Frequently asked questions

What is new in ApexSankey 1.11?

A whole layer of motion, interaction, projections, theming and extensibility on top of the existing flow model. update(data) now springs nodes and ribbons to a new layout when the topology is unchanged, and morphs through it when the topology differs: entering flows unfurl out of their source node, survivors slide, removed flows retract and dissolve. The same nodes-and-edges model now renders as three projections: the layered Sankey, an alluvial diagram via buildAlluvialData plus axisTitles, and a chord diagram via type: 'chord'. It also adds vertical orientation, legible circular and cyclic links, five built-in themes with registerTheme for brand presets, draggable nodes, particle flow, click-to-focus isolation, a plugin API with a typed event bus, three built-in plugins, and ApexSankey.compare() for a before/after split-view with a structural diff.

Is ApexSankey 1.11 a breaking change?

No. The release is fully additive and non-breaking: the 1.10.0 constructor and options keep working unchanged, and existing charts render as they did. Internally it was refactored to a Model, Layout, Renderer, Motion, Interaction pipeline on a shared spring engine, with no public API change. One internal method, renderEdge, is no longer part of the surface, which affects you only if you were calling it directly.

How do I animate an ApexSankey diagram to new data?

Call update(data) on an already-rendered instance instead of render(data). When the new data shares the current topology (the same nodes and flows, only different values or positions), nodes and ribbons spring to their new places. When the topology differs, the diagram morphs through the change. It redraws instantly instead when animation is disabled or the user prefers reduced motion, and the output at rest is identical either way. The diff keys on node id and on the source, target and type triple for flows, so stable ids and a stable flow type matter.

Can ApexSankey render a chord diagram?

Yes. type: 'chord' draws the same nodes-and-edges model as a ring, with nodes as arcs and flows as ribbons across the interior. It is the right projection when everything connects to everything and there are no meaningful ranks: migration between regions, transfers between accounts, traffic between services. arcCornerRadius rounds the outer corners of each node arc while the inner edge where ribbons meet stays flush. Theming, tooltips, interaction, events and animated updates all carry over, since a chord is a projection of the same model rather than a separate chart.

What is the difference between a Sankey and an alluvial diagram in ApexSankey?

They render the same way; the difference is the input. A Sankey takes nodes and edges directly. An alluvial diagram starts from a table of subjects and their category at each of several dimensions, which is what buildAlluvialData converts: you pass dimensions (ordered axis ids) and records (each with a category per dimension and an optional weight), and it returns the nodes and edges to render. Pair it with the axisTitles option for the dimension labels. It is the shape you want for cohort flow, plan-tier migration over time, or before-and-after survey answers.

Does ApexSankey have a plugin system?

Yes. on and off subscribe to a typed event bus (node:click, node:mouseenter, node:mouseleave, the edge equivalents, plus rendered and destroyed), and use(plugin) installs a plugin whose install function receives a context and may return a teardown that runs on destroy(). Three plugins ship built in: pathTrace sends a pulse along the flows connected to a picked node, timePlayback plays through ordered frames with a play/pause control and scrubber, and drillDown collapses groups of nodes into super-nodes that expand on interaction. The pure transform behind drillDown is exposed as ApexSankey.collapseGroups so you can seed an already-collapsed first render.