Skip to main content

NetworkChart

Flow and Hierarchychart

A node-link graph in three layouts — a settled force cloud, a ring of chords, or a bipartite colonnade — with hover/focus adjacency emphasis.

Seven services in three groups, arranged on a ring.
import { NetworkChart } from "@elabs-ai/components-charts";
Install
npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts

Skipping the @source line is the most common first failure: Tailwind does not scan node_modules, so every component renders unstyled. The one-time wiring

Prompt for your coding agent

Paste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.

Add the brand-ui NetworkChart chart (from @elabs-ai/components-charts) to this project.

1. Read https://elabs-ai.com/llms.txt first: it maps every package and the rules. If you can use MCP servers, connect https://elabs-ai.com/mcp and use its search, docs and tokens tools. If not, run the same lookups in the terminal: `npx -y @elabs-ai/components-cli search "<what you need>"` and `npx -y @elabs-ai/components-cli docs <Component>`.
2. Look NetworkChart up before writing code: `npx -y @elabs-ai/components-cli docs NetworkChart` prints its real props, variants, what it pairs with and what to avoid.
3. If @elabs-ai/components-charts is not installed yet: `pnpm add @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts` (or `npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts`). If this is the first brand-ui package in the project, wire it once: import `@elabs-ai/components-tokens/styles.css` plus the `themes/light.css` and `themes/dark.css` stylesheets in the CSS entry, add one Tailwind `@source` line per installed @elabs-ai package (Tailwind does not scan node_modules, so without it everything renders unstyled), and wrap the app root in `ThemeProvider` from `@elabs-ai/components-tokens`.
4. Use it here: [the screen or file, and what it should show]
5. Rules: use only @elabs-ai/components-* components and the semantic tokens — no raw colours, no raw font sizes, no arbitrary Tailwind values. Look up each component's real props with the docs tool before you use it; never guess a prop.
6. Done means: the typecheck passes, `npx -y @elabs-ai/components-cli audit src --strict` reports nothing, and you have opened the app and looked at every screen you touched in the light and the dark theme.

Use it for

  • How does it connect or flow? What connects to what.
  • arbitrary node and edge relationships with no hierarchy

Avoid

  • the relationship really is a hierarchy — use a tree chart or a treemap
  • Reading a force layout's POSITIONS as data — only adjacency is encoded; distance, direction and the picture's orientation are artefacts of the solver's seed. Use layout="circular" or "arc" whenever position must mean something.
  • Labelling every node past roughly 15 — set `labelThreshold` so only the nodes worth naming carry text; the datapoint targets already announce every node to a screen reader.
  • Reaching for layout="arc" on a graph that is not bipartite — the two columns are DERIVED (exactly two groups, else the nodes that are only ever a link source go left), so a general graph draws a colonnade that asserts a split the data does not have.
  • Turning `draggable` on and expecting the dragged position to persist — a node springs back on release because the settled layout is the answer; a pinned position would be a claim about the data that the layout never made.
  • Passing thousands of nodes because `maxNodes` is only a warning — the force solve is synchronous, so past a few hundred nodes it blocks the frame that renders it. Aggregate into hubs before the chart, not inside it.

Works with

Examples

8 live examples

Small Circular

**G6 — the small ring.** Twelve nodes, every one labelled, chords bundled toward the centre so a dense middle still reads. The ring is the right answer under ~15 nodes: position carries no meaning, so nothing is lost by fixing it, and the eye gets a stable place to look each node up.

Loading

`status="loading"` (RM-184): the skeleton + `ChartLoadingLabel`, until the data arrives.

Small Force

**G11 — the small force cloud.** The same graph, laid out by attraction and repulsion instead of by decree: clusters that talk to each other end up together, and the Data tier falls out on its own. The simulation does NOT tick. It is solved to a fixed 300-tick budget from a seeded start before React sees a coordinate, so the picture is identical on every render, in every process — and nothing moves under `prefers-reduced-motion`.

Big Circular

**B1 — the big ring.** Sixty nodes is past the point where every label fits, so `labelThreshold` names only the people worth naming; the rest stay dots. Hover or focus any node: it and its neighbours stay lit and everything else blurs, which is the only way a 140-chord ring answers "who does this one work with". The blur is a CSS class on each `<g>`, driven by ONE piece of state — no node re-renders on hover, which is what keeps it at 60 fps here and at 180 nodes.

Big Force

**B2 — the big force cloud.** 180 nodes, hubs and satellites. The whole layout is solved synchronously in well under the 500 ms budget, and it is the SAME layout every time.

Ownership

**L12 — the ownership colonnade.** Two columns, hairline béziers, and no node `value` at all: with nothing to size by, a node's radius follows its DEGREE, so a team that owns five services reads as a hub without anyone computing a number for it. The split is derived, not declared — exactly two groups puts one on each side; failing that, the nodes that are only ever a link's `source` go left.

Draggable

**Drag (force only).** Pull a node aside to look under a cluster; it springs back to its settled position on release, because the settled layout IS the answer and a dragged position would be a lie about the data. Dragging is a pointer affordance. The keyboard path sits beside it: one tab stop into the chart, arrows across every node, each target named with its degree. That path is mounted by `onDatapointClick` (or `copyValueOnActivate`), not by `draggable` — so this story sets a handler, and its play function proves the path is really there (#274).

Keyboard Drilldown

Every node is a keyboard datapoint target — a real `<button>` in a `pointer-events: none` layer BESIDE the `aria-hidden` SVG, never inside it. The target's own accessible name carries the node's degree, so the answer a sighted user reads in the tooltip reaches a screen-reader user too.

API

NetworkChart

PropTypeDescription
messagesChartMessagesmessages group (RM-187): this chart's own words, keyed by the ui catalogue's `charts.*` message keys. A key set here wins over the `LocaleProvider`; every other key reads the catalogue as before.
nodesrequiredNetworkNodeDatum[]The graph's nodes. `id` is the identity `links` reference.
linksrequiredNetworkLinkDatum[]The graph's edges. An endpoint naming an unknown node is dropped (dev warning).
layoutrequiredNetworkLayout- `"force"` — a settled force-directed cloud (G11, B2). - `"circular"` — one ring, chords bundled toward the centre (G6, B1). - `"arc"` — bipartite ownership: two columns, hairline béziers (L12).
nodeSize"value" | number`"value"` (default) — AREA is proportional to the node's weight, i.e. radius ∝ √weight. A number pins every node to that radius. "Weight" is the node's own `value`, or its DEGREE when it has none — which is what gives a colonnade "hub radius ∝ link count" for free.
labelThresholdnumberDraw a node's label only when its weight reaches this (lieflat B1's rule for a 60-node ring). Omit to label every node — right for ≤ ~15 nodes, unreadable above it.
emphasisNetworkEmphasis`"adjacency"` (default) — hovering or focusing a node keeps it and its neighbours lit and blurs the rest. `"none"` disables it.
draggableboolean`force` only: let the pointer pull a node out of place. It springs back to its settled position on release — the layout is the answer, the drag is a way to look under a cluster. Ignored (with a dev warning) on the other two layouts, which have no free positions to give.
paletteChartPaletteColour family. Default: `"categorical"` at or under six groups, `"mono"` above — past six, a hue per group names nothing.
maxNodesnumberDev-warning threshold on node count. Default 200. NOT a cap.
seednumber`force` only: changes the starting cloud, and so which arrangement is found.
valueFormatChartValueFormatHow node values render in the tooltip. Default `"compact"`.
classNamestring
styleCSSProperties
aspectRatiostringAspect ratio as "width / height". Default `"16 / 9"`.
plotHeightResponsive<ChartPlotHeight>The plot's own height (ADR 0039): px, or `{ aspect }` (width ÷ height), optionally per breakpoint. Wins over `aspectRatio`, which stays an alias.
accessibleLabelChartA11yProps["accessibleLabel"]Accessible name for the chart region. Defaults to the auto summary — `"Network, 60 nodes, 140 links, 5 groups"` — because the counts are exactly what an `aria-hidden` SVG withholds.
accessibleDescriptionChartA11yProps["accessibleDescription"]Supplemental description read by AT.
statusChartStatusShow the loading skeleton until the data is ready (ADR 0042 §9). Default `"ready"`.
emptyChartEmptyStateTitle and message shown when there is nothing to plot.
onDatapointClickChartDatapointClickHandler<NetworkDatapointDatum>Fires when a datapoint is activated by pointer OR keyboard. Setting it is what enables the interaction layer — with it unset the chart renders exactly as before, with no extra DOM and no new focusables.
copyValueOnActivatebooleanPut the datapoint's EXACT value on the clipboard when it is activated — the recovery path for compact axis labels and tooltips, reachable by mouse AND keyboard because activation rides `ChartDatapointLayer`'s real buttons. Default `false` on a raw chart container: turning it on mounts the interaction layer, and the documented guarantee is that a chart with no interaction props renders byte-identical DOM. `AutoChart` — the spec-driven surface, where the caller never sees the axis config — turns it on by default. A consumer-supplied `onDatapointClick` always wins; the two never both run.
datapointLabelChartDatapointLabel<NetworkDatapointDatum>Override the accessible name of each keyboard target.
maxInteractiveDatapointsnumberDev-warning threshold on the number of keyboard targets. Default 500. NOT a functional cap — every plotted point stays reachable, so a keyboard user can reach exactly what a mouse user can click.

Also accepts: ChartInteractionProps<NetworkDatapointDatum>

Theming

The tokens each state resolves to. Change the token and every theme follows.

StateToken
node--chart-1 … --chart-12 by group via resolvePalette (categorical at or under 6 groups, mono above); the mark is stroked in the paper colour (--chart-background) so touching nodes still read apart
link--chart-grid hairlines at 0.35 opacity, width scaled by the link's own value
emphasishover/focus dims non-neighbours to opacity 0.12 and non-incident links to 0.03 — a CLASS on each <g>, transitioned on the compositor, so no node re-renders per frame
labelHaloText in the chart-source type role, punched from var(--chart-background); drawn only at or above labelThreshold
focusevery node is a real button in ChartDatapointLayer beside the aria-hidden SVG (never inside it), carrying the shared compound focus indicator (--ring plus the --ring-contour outline), shown only on keyboard focus; one tab stop, arrows across the rest