3D Network Graph
A four-state 3D node-link graph on react-three-fiber — degree-sized spheres, one line-segment pass for all edges, a deterministic golden-angle layout and theme tokens resolved into WebGL materials at runtime.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-3d-network.jsonPrompt
Build a React + TypeScript + Tailwind "Chart3dNetwork" card that renders a 3D
node-link graph with three, @react-three/fiber and @react-three/drei, driven
by one zod contract.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
nodes: { id, label, group? }[]; links: { source, target }[] }.
Links are undirected; group maps a node onto one of the five chart tokens.
- Component props = z.infer of the schema, plus nodeSize?: number (largest
sphere radius, clamped), onRetry?: () => void and the usual div props
spread on the root. No hand-written parallel interface.
Behavior
- Four first-class branches inside one bg-card panel; loading, empty and
error are plain DOM, so SSR never touches WebGL:
- loading: a pulsing circles-and-hairlines silhouette, aria-hidden with
motion-reduce:animate-none, plus an sr-only role="status" line.
- empty: icon + "Nothing connected yet" copy (also when ready arrives
with zero nodes).
- error: message + a "Try again" button only when onRetry exists.
- ready: the <Canvas>, gated behind a mounted flag (server snapshot
false, client true via useSyncExternalStore); until mounted — and until
token colors are resolved — render the same skeleton, so server HTML
and the first client paint agree.
- Graph build: dedupe node ids, drop self-links / unknown endpoints /
duplicate pairs but count them and say so in a role="status" line, then
compute each node's degree.
- Layout is deterministic and lives in the component: seed nodes on a
golden-angle (Fibonacci) sphere, then run a fixed number of relaxation
steps — a spring along every link toward a rest length, inverse-square
repulsion between every pair, a light pull to the origin, displacement
capped by a linearly cooling step — and finally recenter + rescale the
cloud to a fixed radius. No randomness, so the same graph always lands in
the same shape.
- Nodes are spheres, radius = min + (max − min) · sqrt(degree / maxDegree);
all edges share ONE lineSegments BufferGeometry (a single draw call),
disposed on replace and on unmount.
- Hover via R3F pointer events (stopPropagation so only the sphere nearest
the camera reacts) lifts the sphere's emissive and shows a drei <Html>
tooltip — label · degree · group — with pointer-events: none; the
pointerout reset is guarded against out-of-order enter/leave.
- OrbitControls with damping, pan disabled, zoom clamped; autoRotate ONLY
when prefers-reduced-motion does not match (tracked live via a matchMedia
change listener that is removed on unmount).
Rendering & styling
- Never hand THREE a literal color. Resolve tokens at runtime:
getComputedStyle(document.documentElement).getPropertyValue("--chart-1")
etc., normalized through an offscreen 1×1 2D canvas (fillStyle → fillRect
→ getImageData) because THREE.Color cannot parse oklch, then
new THREE.Color().setRGB(r/255, g/255, b/255, SRGBColorSpace).
Re-resolve when a MutationObserver sees the class change on <html> (theme
flip); disconnect the observer on unmount.
- Materials: meshStandardMaterial per node with chart-1..5 by group slot
under ambientLight + directionalLight; edge lines and the reference
gridHelper use the resolved border / muted-foreground tokens.
- <Canvas dpr={[1,2]}> inside an aspect-[16/10] w-full wrapper that has
role="img" and a descriptive aria-label; a full sr-only summary (counts,
busiest node, groups, "distance is not data") sits beside the canvas.
- DOM-side legend dots (one per group, shown only when there are 2+) use
var(--chart-N) directly; tooltip and panel use bg-popover / bg-card /
border / text-muted-foreground; cn() merges className.
Customization levers
- Frame & camera: the aspect-[16/10] wrapper, fov ≈ 42 and the orbit
min/max distances set how imposing the cloud feels.
- Sphere scale: nodeSize is the largest radius; the min:max ratio (0.45)
decides how loudly degree speaks against the quiet nodes.
- Layout feel: rest length up = airier, repulsion up = rounder shell, more
relaxation steps = tidier clusters; cost is O(n² · steps), comfortable to
~150 nodes — aggregate upstream past that.
- Motion: autoRotateSpeed (0 parks it entirely), dampingFactor for orbit
inertia; reduced-motion handling must stay.
- Palette: the group → var(--chart-N) slot assignment cycles five tokens;
remap it for fixed brand colors per group.
- Chrome: drop the gridHelper for a free-floating cloud, or the legend for
a thumbnail embed; put link weight into edge opacity if the contract
grows a value field.Concepts
- Mounted canvas gate — the WebGL canvas only exists after the client says so: the server and the hydration pass both render the same plain-DOM skeleton, so there is no SSR crash and no hydration mismatch, and the loading / empty / error branches never construct a GL context at all.
- Runtime token resolution — theme tokens are oklch strings THREE cannot parse, so each one is painted onto a 1×1 offscreen 2D canvas and read back as sRGB pixels; a MutationObserver on the html class re-runs the resolve on theme flip, which is what makes a WebGL scene re-theme like any Tailwind div.
- Deterministic golden-angle layout — nodes seed onto a Fibonacci sphere and relax for a fixed number of capped, cooling steps with zero randomness: the same graph produces the same picture on every client, so screenshots, previews and diffs never churn.
- Degree-sized spheres — a node's radius carries its connectivity on a sqrt scale, so hubs read as mass from any camera angle; position deliberately carries nothing, and the copy under the chart says so.
- One geometry for all edges — every link lives in a single lineSegments buffer: one draw call whether there are 20 edges or 2000, and one dispose() when it is replaced or unmounted, because GPU buffers do not garbage-collect themselves.
- Reduced-motion contract — the idle auto-rotation is decoration and switches off under prefers-reduced-motion (live, via matchMedia), while hand-driven orbit and zoom keep working: turning off motion never costs function.
3D Donut
An extruded WebGL donut on react-three-fiber — hovered slices lift and slide outward, the total floats in the hole, and a DOM legend keeps the numbers honest.
3D Funnel
A four-state pipeline funnel of stacked 3D truncated cones — orbitable with damped controls, per-stage conversion labels, and theme tokens resolved into WebGL materials at runtime.