Networks and hierarchies

Network and hierarchy charts show how things nest or connect, from trees and treemaps to force layouts and flows between stages.

LLMs

Code

Trees, treemaps, sunbursts, networks and sankeys are drawn with TanStack Charts, through the kit's Chart components. They paint the chart with the theme's colors, grid and type, and add three palette props. The chart itself is a TanStack definition, written the way TanStack documents it.

Install

npx antuikit@latest init
npx antuikit@latest add tanstack-chart
The first command installs AntuikitProvider and the theme tokens. The second adds the chart components and TanStack Charts itself.

Your first tree

treeLayout turns flat rows into positioned nodes and links. Draw the links with link, the nodes with dot and the names with text, on two linear scales.
import { defineChart, dot, link, text } from "@tanstack/charts"
import { treeLayout } from "@tanstack/charts/hierarchy/tree"
import { scaleLinear } from "@tanstack/charts/scales/linear"
import { Chart, chartColors } from "@/components/ui/tanstack-chart"
const classes = [
{ name: "flare.analytics.cluster.MergeEdge" },
{ name: "flare.analytics.graph.LinkDistance" },
{ name: "flare.analytics.graph.SpanningTree" },
]
const tree = treeLayout(classes, { path: "name", delimiter: "." })
const definition = defineChart({
marks: [
link(tree.links, {
x1: "x1",
y1: "y1",
x2: "x2",
y2: "y2",
key: "id",
stroke: chartColors.axis,
}),
dot(tree.nodes, { x: "x", y: "y", key: "id", r: 3.5 }),
text(tree.nodes, {
x: "x",
y: "y",
text: "name",
key: "id",
fill: chartColors.text,
anchor: (node) => (node.internal ? "end" : "start"),
dx: (node) => (node.internal ? -6 : 6),
}),
],
scales: {
x: { scale: scaleLinear },
y: { scale: scaleLinear },
},
guides: false,
margin: { top: 16, right: 150, bottom: 16, left: 64 },
})
export function PackageTree() {
return (
<Chart
definition={definition}
ariaLabel="Tree of the analytics package and its classes"
height={260}
/>
)
}
The chart fills its container's width, so give it a height. ariaLabel is required. Leave room in margin for the labels at both ends, since they are drawn outside the nodes.

Which component

All three take the same definition and the same palette props.
Chart components

Hierarchy data

treeLayout, treemap and sunburst read the same flat rows, one per node. Give each row its full path, or give it an id and the id of its parent.
// A path per row. Parents that have no row are added for you.
const rows = [
{ name: "flare.analytics.cluster.MergeEdge", size: 743 },
{ name: "flare.analytics.graph.LinkDistance", size: 5731 },
]
treemap(rows, { path: "name", delimiter: ".", value: "size" })
// An id and a parent id per row. The root has no parent.
const teams = [
{ id: "company", parent: null, size: null },
{ id: "design", parent: "company", size: 12 },
{ id: "platform", parent: "company", size: 30 },
]
treemap(teams, { nodeId: "id", parentId: "parent", value: "size" })
  • treeLayout calls the two fields id and parentId. treemap and sunburst use nodeId, because id names the mark.
  • Ids must be unique, every parent must exist, and there must be one root and no cycles. Anything else throws before the chart renders.
  • value is summed up through the parents. Leave it null on parent rows, which then count as zero.
  • Path ids come back in slash form, whatever the delimiter: "flare.analytics" becomes "/flare/analytics". Use that form for rootId.

Layouts and marks

The five charts split into two groups, and the group decides how you set up the scales.
  • treeLayout and forceLayout run once, before the chart, and return rows with x and y. Draw them with ordinary marks on linear x and y scales. Resizing the chart does not run the layout again.
  • treemap, sunburst and sankeyDiagram are marks that lay themselves out in the chart's final pixels. Turn the scales and guides off with scales: { x: null, y: null } and guides: false. They lay out again when the chart resizes, so a treemap may tile differently at another width.
Run the layouts outside the component, or in useMemo when the data comes from props. They are not cached.

Treemap

Import treemap from its own entry. It draws one cell per leaf, sized by value, and prints label inside the cells it fits in.
import { defineChart } from "@tanstack/charts"
import { treemap } from "@tanstack/charts/hierarchy/treemap"
const definition = defineChart({
marks: [
treemap(classes, {
path: "name",
delimiter: ".",
value: "size",
ratio: 4 / 3,
round: true,
color: (node) => node.data?.name.split(".")[2] ?? node.name,
inset: 1,
stroke: chartColors.surface,
label: "name",
labelFill: "var(--ant-color-text-light-solid)",
}),
],
scales: { x: null, y: null },
color: { domain: ["cluster", "graph", "optimization"] },
guides: false,
margin: 0,
})
color here returns the package each class belongs to, so a package and its classes share a color. A label on a filled cell needs a light text color to stay readable.

Sunburst

A sunburst goes inside polar. It takes the same rows as a treemap and draws one ring per level.
import { defineChart } from "@tanstack/charts"
import { sunburst } from "@tanstack/charts/hierarchy/sunburst"
import { polar } from "@tanstack/charts/polar"
const definition = defineChart({
marks: [
polar({
startAngle: Math.PI / 2,
endAngle: Math.PI / 2 - Math.PI * 2,
marks: [
sunburst(classes, {
path: "name",
delimiter: ".",
value: "size",
innerRadius: ({ radius }) => radius * 0.14,
ringPadding: 2,
color: "branchId",
stroke: chartColors.surface,
strokeWidth: 2,
}),
],
scales: { angle: null, radius: null },
}),
],
scales: { x: null, y: null },
margin: 0,
})
color: "branchId" gives each top branch one color, shared by everything inside it. startAngle and endAngle on polar set where the rings begin and which way they run.

Drill down

For a large hierarchy, show one or two levels below a root and let the reader open a branch. Keep the root in state, rebuild the definition when it changes, and set it from onSelect, which fires on click and on Enter or Space.
const [rootId, setRootId] = useState("/flare/analytics")
const renderer = useMemo(() => motion({ initial: "always" }), [])
const definition = useMemo(
() =>
defineChart({
marks: [
polar({
marks: [
sunburst(rows, {
path: "name",
delimiter: ".",
value: "size",
rootId,
visibleDepth: 2,
}),
],
scales: { angle: null, radius: null },
}),
],
scales: { x: null, y: null },
}),
[rootId],
)
<RendererChart
definition={definition}
renderer={renderer}
ariaLabel="Drillable sunburst of the Flare packages"
ariaDescription="Press Enter on a segment to open it, and use the center button to go back up."
height={280}
onSelect={(point) => {
if (point && !point.datum.external) setRootId(point.datum.id)
}}
/>
  • With the motion() renderer, the rings unfold from the branch you opened and fold back when you go up.
  • Add a visible button that goes back to the parent. The chart has no back control of its own.
  • Hidden levels still count toward the values of the rings you see.

Force-directed network

A network takes two arrays: nodes with an id, and links that name the ids they connect. forceLayout runs the simulation to the end and returns settled nodes, links with both ends, and the domains for the scales.
import { defineChart, dot, link, text } from "@tanstack/charts"
import { forceLayout } from "@tanstack/charts/network/force"
import { scaleLinear } from "@tanstack/charts/scales/linear"
const characters = [
{ id: "Myriel", group: 1 },
{ id: "Valjean", group: 2 },
]
const scenes = [{ source: "Valjean", target: "Myriel", value: 5 }]
const graph = forceLayout(characters, scenes, {
nodeKey: "id",
source: "source",
target: "target",
forces: [
{ type: "link", distance: 42 },
{ type: "manyBody", strength: -165 },
{ type: "center", x: 0, y: 0 },
{ type: "collide", radius: 15 },
],
})
const definition = defineChart({
marks: [
link(graph.links, {
x1: "x1",
y1: "y1",
x2: "x2",
y2: "y2",
key: ({ source, target }) => `${source}->${target}`,
stroke: chartColors.axis,
}),
dot(graph.nodes, { x: "x", y: "y", color: "group", key: "id", r: 7 }),
text(graph.nodes, { x: "x", y: "y", text: "id", key: "id", dy: -12 }),
],
scales: {
x: { scale: scaleLinear().domain(graph.xDomain) },
y: { scale: scaleLinear().domain(graph.yDomain) },
},
color: { domain: [1, 2, 3] },
guides: false,
})
  • The same input in the same order settles the same way, so keep the order of nodes and links stable when charts must match.
  • collide keeps nodes from overlapping. Set its radius a little larger than the dot.
  • The layout is static. For dragging nodes, run a live simulation yourself, keep its positions in state and draw them with the same marks.

Sankey

sankeyDiagram takes nodes, weighted links, and a marks function. The function receives the laid out nodes and links in pixels and returns the marks that draw them.
import { d3Curve, defineChart, link, rect, text } from "@tanstack/charts"
import { sankeyDiagram } from "@tanstack/charts/network/sankey"
import { curveBumpX } from "d3-shape"
const steps = [
{ id: "orders", label: "Orders" },
{ id: "berlin", label: "Berlin" },
{ id: "lyon", label: "Lyon" },
{ id: "delivered", label: "Delivered" },
]
const flows = [
{ source: "orders", target: "berlin", value: 772 },
{ source: "orders", target: "lyon", value: 512 },
{ source: "berlin", target: "delivered", value: 772 },
{ source: "lyon", target: "delivered", value: 512 },
]
const definition = defineChart({
marks: [
sankeyDiagram({
nodes: steps,
links: flows,
nodeKey: "id",
source: "source",
target: "target",
value: "value",
align: "left",
inset: { left: 64, right: 64, top: 16, bottom: 16 },
marks: ({ nodes, links }) => [
link(links, {
x1: "x1",
y1: "y1",
x2: "x2",
y2: "y2",
key: "key",
stroke: chartColors.series[0],
strokeOpacity: 0.3,
strokeWidth: (flow) => Math.max(1, flow.width),
lineCap: "butt",
curve: d3Curve(curveBumpX),
}),
rect(nodes, {
x1: "x0",
x2: "x1",
y1: "y0",
y2: "y1",
key: "key",
fill: chartColors.series[0],
inset: 0,
}),
text(nodes, {
x: (node) => (node.depth === 0 ? node.x0 - 8 : node.x1 + 8),
y: "y",
text: (node) => node.data.label,
key: "key",
anchor: (node) => (node.depth === 0 ? "end" : "start"),
}),
],
}),
],
scales: { x: null, y: null },
guides: false,
margin: 0,
})
  • Each node keeps your row on data, so labels and colors can read any field you added, such as a profit or cost tone.
  • Leave room for the labels with inset. It also takes a function of the chart size, for labels on narrow screens.
  • Set nodeSort to keep the nodes of a column in your order, such as profit above cost.
  • Values must be zero or more, and the links cannot form a cycle.
  • curveBumpX comes from d3-shape. TanStack Charts installs it, but add it to your own dependencies when you import from it.

Tooltip

Nodes and links both get the tooltip, so one tooltip function receives either. Tell them apart before you read their fields. Sankey rows carry kind, and tree nodes carry internal.
import { tooltip } from "@tanstack/charts/tooltip"
defineChart(spec, {
tooltip: {
use: tooltip,
content: (points) => ({
rows: points.map(({ datum, color }) =>
datum.kind === "node"
? { label: datum.data.label, value: datum.value, color }
: {
label: `${datum.sourceNode.data.label} to ${datum.targetNode.data.label}`,
value: datum.value,
color,
},
),
}),
},
})

Colors

Color a hierarchy by its top branch and a network by a group in the data. Fix the order with color: { domain } so a group keeps its color when the data changes, and switch to palette="multi" when the groups need distinct hues. The Color guideline covers which palette fits which data.
<Chart definition={definition} ariaLabel="..." palette="multi" />
// A treemap colored by package, in a fixed order.
treemap(classes, {
path: "name",
delimiter: ".",
value: "size",
color: (node) => node.data?.name.split(".")[2] ?? node.name,
})
defineChart({
marks: [/* ... */],
color: { domain: ["cluster", "graph", "optimization"] },
})
Palette props
Taken by Chart, RendererChart and CanvasChart. Rows marked antuikit in the Version column are added by the kit.

Animation

Use RendererChart with TanStack's motion() renderer to animate marks in and between data updates. It skips the animation when the reader has asked for reduced motion.
import { motion } from "@tanstack/charts/motion"
import { RendererChart } from "@/components/ui/tanstack-chart"
const renderer = motion({ initial: "always" })
<RendererChart definition={definition} renderer={renderer} ariaLabel="..." />

API

The options each layout and mark uses most. TanStack Charts' documentation has the full reference.
treeLayout
treemap
sunburst
forceLayout
sankeyDiagram