Skip to main content

Graph explorer

@prnt/dagr-explorer is a React component for reading a graph rather than drawing one: views of the same system, search, connection tracing, groups and a details drawer, over a pannable, zoomable surface. It draws with DOM and SVG, not WebGPU, and gives a DOM element only to the nodes large enough on screen to read, so a graph of thousands of nodes stays a page of a few hundred elements. Layout is Dagr's own, and runs anywhere, a server included.

Install​

Available on npm

@prnt/dagr-explorer is available on npm from version 0.1.3.

npm install @prnt/dagr-explorer

It runs on React 18 and React 19 (react and react-dom >=18.2.0 <20.0.0). The umbrella package re-exports it as @prnt/dagr/explorer, but the umbrella requires React 19, so a React 18 site installs @prnt/dagr-explorer directly. It does not depend on @prnt/dagr-react, and it never loads three.js at runtime: from @prnt/dagr-render it imports only the three-free core entry, and @prnt/dagr-render declares three an optional peer, so it is not installed either.

The explorer​

import { DagrExplorer } from '@prnt/dagr-explorer';
import '@prnt/dagr-explorer/styles.css'; // optional

<DagrExplorer
label="Architecture"
views={[
{
id: 'overview',
label: 'Overview',
nodes: [
{ id: 'app', label: 'Application', team: 'web' },
{ id: 'store', label: 'Store', team: 'data' },
],
edges: [{ id: 'read', source: 'app', target: 'store' }],
},
]}
renderNode={(node) => `${node.label} (${node.team})`}
/>;

A node is an id and a label, and those are all the explorer reads. Everything else is your own fields, and the node type is inferred from your data, so renderNode above sees team. label on the explorer is required: it is the graph's accessible name, and the parts derive theirs from it.

For one graph, pass nodes, edges, and optionally groups and layout, instead of views. Passing both shapes is a type error.

A view's layout sets direction ('right' by default, or 'down'), a nodeSize (240 by 120 by default, a size or a function of the node), nodeSep, rankSep and edgeStyle. Sizes are declared, never measured: a node with no element cannot be measured.

Try it​

Tidewater is an invented ferry booking system in two views. The overview flows right and outlines four groups. The deployment view flows down, outlines what runs on devices and what runs at the edge, and dashes every edge that crosses from one place to another. Both bow the parallel edges between Booking and its database, and between Payments and the card processor, apart.

Search for a name or a kind (try store), press Enter to fly to the first match, and open a node to see its details and connections. Turn on trace to dim everything not joined to the selected node. Zoom in and the nodes go from marks to labels to cards showing the kind, the label and a sentence: that is renderNode at the summary and rich tiers.

20 nodes, 23 edges. Search reaches every node. In the graph, the arrow keys move between nodes and Shift with an arrow pans. Click the graph to zoom with the wheel, drag to pan, and press Escape to leave it.

What the demo passes, beyond views:

<DagrExplorer
label="Tidewater architecture"
views={architectureViews}
searchText={(node) => `${node.id} ${node.label} ${node.kind}`}
renderNode={(node, { tier }) =>
tier === 'rich' ? (
<>
<span className="kind">{node.kind}</span>
<span className="label">{node.label}</span>
<span className="summary">{node.summary}</span>
</>
) : (
node.label
)
}
renderDetails={({ node, connections, follow }) => (
<>
<p>{node.label}</p>
<dl>
<dt>Kind</dt>
<dd>{node.kind}</dd>
<dt>Owner</dt>
<dd>{node.owner}</dd>
</dl>
<ul>
{connections.map(({ edge, node: other }) => (
<li key={edge.id}>
<button type="button" onClick={() => follow(other.id)}>
{other.label}
</button>
</li>
))}
</ul>
</>
)}
/>

searchText is what search reads from a node, by default the id and label. renderNode's output sits inside the explorer's own node button, so it must not be interactive. renderDetails replaces the drawer's body. inspect opens another node while keeping the drawer's original opener for focus to return to, and follow does the same and pans the node into view at the current zoom, which is what the default list's connection buttons do. Those buttons show an arrow for the edge's direction, read out through labels.connectionTo and labels.connectionFrom.

A reader chooses between the map and an outline of the same nodes with the Map and Outline buttons, at any width. Pass defaultMode="outline" to start on the outline, or mode with onModeChange to control it. Selection, search, trace and the open drawer carry across a switch, and the map keeps its pan and zoom while hidden, following a node chosen in the outline. The outline keeps its scroll position while the map shows, unless focus moving to a node needs that node in view. The open drawer keeps its scroll position, and focus in it stays on the same control. Focus elsewhere in the presentation that leaves moves to the same node in the other.

The parts and your own layout​

DagrExplorer is built only from public parts, with no private access, so a host that owns its layout can rebuild it or arrange the parts differently:

import {
ExplorerDetails,
ExplorerRoot,
ExplorerSearch,
ExplorerToolbar,
ExplorerViewport,
type ExplorerNode,
type ExplorerView,
} from '@prnt/dagr-explorer';

interface MyNode extends ExplorerNode {
readonly title: string;
}

const views: ExplorerView<MyNode>[] = [
{
id: 'overview',
label: 'Overview',
nodes: [
{ id: 'app', label: 'Application', title: 'App' },
{ id: 'store', label: 'Store', title: 'Store' },
],
edges: [{ id: 'read', source: 'app', target: 'store' }],
},
];

export function Architecture() {
return (
<ExplorerRoot label="Architecture" views={views}>
<ExplorerSearch />
<ExplorerViewport<MyNode> renderNode={(node) => node.title}>
<ExplorerDetails />
</ExplorerViewport>
<ExplorerToolbar />
</ExplorerRoot>
);
}
PartOwns
ExplorerRootthe data, its validation and layout, and the state. Renders one element around its children
ExplorerViewsthe view switcher. Renders nothing for a single view
ExplorerSearchthe search field, a live match count, and up to maxResults matches as buttons (default 50)
ExplorerTraceToggletrace on and off
ExplorerViewportthe graph: pan and zoom, the base layer, and node elements for nodes large enough to read
ExplorerDetailsthe drawer: an overlay with a close button and a scrolling body
ExplorerOutlinethe active view's nodes as a list of buttons in data order, on the same selection, query, trace and drawer as the graph. Works with or without a viewport
ExplorerToolbarzoom out, the zoom readout, zoom in, fit, and zoom to the selected node. Zoom out, zoom in, fit and the drawer's close button are icons, named and titled by their labels

Children of ExplorerViewport, such as ExplorerDetails, share a positioned stage with the graph, so the drawer overlays the graph and not the hint below it. The camera frames the part the drawer leaves uncovered: fit, focus, reveal and zoom work beside it, and a node under it can be panned out. Pass inset={{ right: 320 }} (CSS pixels per side) for an overlay of your own, and contentPadding (default 0.05, in [0, 0.45]) for how far content may be panned past the frame's edge, to ExplorerViewport or DagrExplorer. One viewport per root: a second throws ExplorerContextError with the code SECOND_VIEWPORT, and a part outside a root throws it with OUTSIDE_EXPLORER.

Every part takes className and style, and your style wins over the part's own: <ExplorerViewport style={{ height: 600 }} /> sets the graph's height. The viewport keeps its own position and overflow, which place and clip its nodes, and user-select: none, so a pan never selects their text.

A part's type parameters are a claim, not a check. The parts talk through a context, which erases them, so ExplorerViewport<MyNode> asserts the node type and nothing verifies it. DagrExplorer has no such gap: it infers the types from views.

The view and the selection are controllable (viewId, selectedId, their default* props and onViewChange, onSelectedChange), because they are what a host syncs to a URL. Under a controlled value, the explorer calls back and changes nothing on screen until the prop does. The query, trace, the drawer and the camera are internal, and switching view resets them.

useExplorer and useExplorerApi​

Inside a root, useExplorer() returns the explorer's state (views, activeView, layout, selectedId, selectedNode, query, matches, trace, detailsOpen, dimmed, labels, and camera for a readout of your own) and its methods. It re-renders its caller on every change of state.

useExplorerApi() returns the methods alone and never re-renders its caller. Use it for a control that only acts, such as a button of your own:

import { useExplorerApi } from '@prnt/dagr-explorer';

function ShowStore() {
const { inspect } = useExplorerApi();
return (
<button type="button" onClick={(event) => inspect('store', event.currentTarget)}>
Show the store
</button>
);
}

The same methods reach a host outside the root through apiRef: fit(), zoomBy(factor), focusNode(id), reveal(id), focusViewport(), select(id), inspect(id, trigger), closeDetails(), selectView(id), setQuery(query) and setTrace(on). Two calls in one tick see each other, so select('a'); select(null) ends with nothing selected.

Labels​

Every string the parts show or announce comes from labels, whose neutral English defaults are DEFAULT_EXPLORER_LABELS. Pass any subset to ExplorerRoot or DagrExplorer. Counts and names are formatters, so a language with other plural rules is a function, not a template:

const labels = {
search: 'Find a component',
matches: (count: number) => (count === 1 ? 'One match' : `${count} matches`),
};

<DagrExplorer label="Architecture" views={views} labels={labels} />;

An inline object is kept by value, so re-creating it with the same contents changes nothing. An inline formatter is a new function each render, and so a change: define formatters outside the component, as above.

Theming​

The parts work with no stylesheet: what they need to function is inline. @prnt/dagr-explorer/styles.css is the optional default look, and no module imports it. Every part carries a data-dagr-explorer hook (root, search, viewport, node, details, toolbar and others), with state in data-tier, data-selected, data-dimmed, data-dragging and data-active.

The stylesheet reads eight variables, which you set anywhere above the explorer:

VariableFor
--dagr-explorer-accentselection and pressed controls
--dagr-explorer-fgtext
--dagr-explorer-fg-mutedsecondary text, marks and edges
--dagr-explorer-borderborders
--dagr-explorer-bgnodes, controls and the drawer
--dagr-explorer-bg-subtlethe graph's background, hover
--dagr-explorer-focusfocus rings
--dagr-explorer-font-monothe zoom readout

--dagr-explorer-height sets the graph's height, 480px by default, with or without the stylesheet.

The demos on this page follow the site's light and dark modes with nothing but this mapping onto Docusaurus's Infima variables, which already swap per theme:

.theme {
--dagr-explorer-accent: var(--ifm-link-color);
--dagr-explorer-fg: var(--ifm-color-content);
--dagr-explorer-fg-muted: var(--ifm-color-content-secondary);
--dagr-explorer-border: var(--ifm-color-emphasis-300);
--dagr-explorer-bg: var(--ifm-background-surface-color);
--dagr-explorer-bg-subtle: var(--ifm-color-emphasis-100);
--dagr-explorer-focus: var(--ifm-color-primary);
--dagr-explorer-font-mono: var(--ifm-font-family-monospace);
}

/* A plain rule wins over the stylesheet: the site's typeface. */
.theme [data-dagr-explorer='root'] {
font-family: var(--ifm-font-family-base);
}

The stylesheet's selectors are wrapped in :where(), so they have no specificity and any rule of yours wins, as the second rule shows. The exceptions are its :hover, :focus and :focus-visible rules, which weigh as one class each: override those with a class that comes later or a more specific selector.

Tiers and virtualization​

A node's tier comes from its width on screen, in CSS pixels:

TierWidth on screenWhat is on the page
markbelow summary (56)no element: the base layer draws it
summaryfrom summary to below rich (200)a node element, renderNode with tier: 'summary'
richrich and abovea node element, renderNode with tier: 'rich'

The gates are per node, so a large node becomes readable before a small one. Change them with tiers, as { summary, rich }. At the summary tier the default stylesheet counter-scales text, through --dagr-explorer-inv-zoom, so a label stays legible as the node shrinks.

On every camera frame the viewport works out which nodes get an element:

  • the nodes whose box meets the viewport, widened by a quarter of its width and height on each side, and whose tier is summary or rich;
  • at most maxOverlayNodes of those (default 200), nearest the center of the view first, ties broken by id. The rest stay marks;
  • plus the pinned nodes, which always have an element, outside the cap and at least at the summary tier: the selected node, the node in the tab order, and the node with keyboard focus. So the page can hold a few more node elements than the cap.

Everything else in that widened view is drawn by the base layer: the other nodes as marks, the edges and the group outlines. React renders only when which nodes are mounted, or at what tier, changes. A pan inside the widened margin moves the plane with one style write and no React work.

The overlay cap is what keeps a large graph a light page. The live demo at the end of this page counts the node elements it has mounted as you pan. ExplorerOutline has no cap: it mounts a button for every node in the view.

Keyboard and screen readers​

The graph is one tab stop. Exactly one node is in the tab order: the selected node, else the last node you focused from the keyboard, else the node nearest the center of the view. That node always has an element, at any zoom. Tab again leaves the graph.

Key, with a node focusedDoes
Arrow keysmove focus to the nearest node in that direction, on screen or not
Shift with an arrowpans
Enter, Spaceinspect the node

With the graph's surface focused (click it), the arrow keys pan. Either way + and = zoom in, - zooms out and 0 fits. Escape closes the drawer if it is open, keeping focus where it is, and otherwise leaves the graph. Wheel zoom only works while the graph has focus, so the page scrolls past it untouched.

Every camera move but a drag or a pinch eases to where it is going: keys, the wheel, search, the drawer opening or closing, and the fit, zoomBy, focusNode and reveal methods. Under prefers-reduced-motion: reduce it lands there at once. A drag or a pinch follows the pointer either way.

Only nodes on screen and large enough to read have elements, so a screen reader finds only those in the graph. Search is the way to every node. It reaches all of them whatever is on screen, its match count is live, and the graph's accessible description says so and gives the node and edge counts. Enter in the search field inspects the first match and flies to it.

When the drawer closes, focus returns to what opened it, else to the search field, else to the root. An opener now hidden, as the map is behind DagrExplorer's outline, hands focus to that node's control in the presentation that shows. Escape closes the drawer from anywhere in the root.

ExplorerOutline reaches every node too, as a list. It is one tab stop: the selected node's item, else the last one focused, else the first. The up and down arrows, Home and End move between items, and Enter or Space inspects one and pans a graph beside it to bring the node into view. A node the query or trace dims stays in place, marked data-dimmed. Items are named as the map names its nodes: by the outline's own nodeAriaLabel, which takes the viewport's arguments (DagrExplorer passes its one to both), else the label, then labels.inGroup for each group.

Server rendering​

Every part renders on a server, with no DOM. Layout is pure, so the HTML carries the shell and the base layer: every node as a mark and every routed edge. The camera and the node elements start on the client, once the viewport is measured, and until then the plane is hidden, so no unscaled frame is painted. The graph's height is fixed by --dagr-explorer-height (or your style), so nothing shifts when it fits.

The architecture demo above is rendered this way, when this site is built. The large one is not, for weight rather than safety: 2,000 marks in the HTML for a graph below the fold.

The core touches no DOM at all, and layoutView, searchNodes, validateView and validateViews are exported for use on their own. A malformed view throws ExplorerDataError with a code and the offending id, on the server as on the client.

The base layer is experimental​

Nodes too small to read, edges and group outlines are drawn by a base layer, SVG by default. ExplorerViewport's base prop swaps it, through the exported ExplorerBase, ExplorerBaseProps, ExplorerCameraSource, ExplorerEmphasis and ExplorerVisibleSet types. They are a seam with one implementation, which is a guess: they may change when a native base over DagrCanvas lands and confirms or corrects them.

A 2,000 node graph​

A generated system of 40 layers of 50 nodes and 2,661 edges, 123 of them dashed and skipping a layer or two, laid out in your browser. Fitted, every node is a mark except the one in the tab order, which always has an element. Zoom in and the readout shows node elements arriving, and staying within a few of the cap of 200 however far you pan.

This graph is generated and laid out in your browser, so there is nothing to show until the page has loaded.

Limits​

  • The SVG base is smooth to about 4,000 nodes. Measured by panning graphs of 500 to 8,000 nodes at one and a half times the fit zoom, where every node and edge is in the base, in Chromium 153 on an Apple M4 (macOS, 16 GB, device pixel ratio 1), on 2026-10-04. A size is smooth when its 95th percentile frame stays within one 16.7 ms frame. Above the ceiling the answer is a native base, which is not built yet.

    NodesEdgesMedian frame95th percentileFrames dropped
    50064316.7 ms18.3 ms (1 frame)0%
    1,0001,31416.7 ms18.3 ms (1 frame)0%
    2,0002,66116.7 ms18.1 ms (1 frame)0%
    4,0005,40716.7 ms18.4 ms (1 frame)1%
    8,00010,82616.7 ms33.4 ms (2 frames)11%

    The 95th percentile is counted in frames, because frame timestamps jitter by a millisecond or two: a page moving one div measures 18.2 ms on the same machine. How it was taken is in bench/browser/README.md, and the harness beside it reruns it.

  • A self loop is not drawn. An edge from a node to itself stays in your data, appears in the node's connections, and has an empty route.

  • A group moves no node. It is the padded hull of its members, with a band above for its label, so a group's outline can cover a node that is not a member. Pass strictGroups to throw GROUP_ENCLOSES_NON_MEMBER instead, for a diagram where that would be a false statement. The demo's groups are chosen so none does, and a test lays them out with strictGroups to keep it so. The canvas's node groups behave the same way.

  • Parallel edges bow apart only on one rank. Edges joining the same two nodes are separated by 16 when they span a single rank. Across more ranks the layout already routes them apart.

Mobile framework contract​

Explorer and DagrCanvas use the same createPinchGesture core primitive, while Explorer retains focus-gated page scrolling and its own camera adapter. Responsive details shipped separately in M5.7b. ExplorerOutline and DagrExplorer's map or outline choice make up the M5.7c outline reader. See Mobile architecture for the contract and validation limits.