Network graph
Nodes on rings joined by hairlines. A signal ripples out from the center and lights every node it reaches. Point or Tab to see connections.
One of 3 components in Data animations
Details
What Network graph brings along, read from the registry and the source.
- Category
- Data animations
- Area
- Visuals
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/network-graph.tsx
- Brings along
- utilskitmotion-scene
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The NetworkGraphProps interface from components/ui/network-graph.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.
nodesrequiredGraphNode[]linksrequired[string, string][]Pairs of node ids
aspectoptionalstringScene ratio, width / height. Default "16 / 10".
intervaloptionalnumberSeconds between two signals at speed 1. Default 3.6.
seedoptionalnumberSeed for the small irregularities of the layout
interactiveoptionalbooleanNodes take focus and show their links. Default true.
labeloptionalstringAccessible description of the whole graph
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role group.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 207 lines
'use client';
import { useState } from 'react';
import { cn, round } from '@/lib/kit';
import { PlayToggle, SCENE_CSS, keyframes, rand, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';
/*
* A connection graph: nodes on rings around a center, joined by hairlines.
* On entry the lines draw outward ring by ring. Then a signal leaves the
* center every few seconds as a ripple, and every node and line lights up
* when the ripple reaches it, so the light always travels outward. Pointing
* at a node or focusing it with Tab shows its name and its connections.
*/
export interface GraphNode {
id: string;
/** Shown on hover and focus, and read to screen readers */
label: string;
/** Avatar, icon or initials inside the node */
content?: React.ReactNode;
/** 0 center, 1 inner ring, 2 outer ring. Default: from the links, counted from the first node. */
ring?: number;
/** Explicit position in percent, overrides the ring layout */
x?: number;
y?: number;
}
export interface NetworkGraphProps extends SceneMotionProps {
nodes: GraphNode[];
/** Pairs of node ids */
links: [string, string][];
/** Scene ratio, width / height. Default "16 / 10". */
aspect?: string;
/** Seconds between two signals at speed 1. Default 3.6. */
interval?: number;
/** Seed for the small irregularities of the layout */
seed?: number;
/** Nodes take focus and show their links. Default true. */
interactive?: boolean;
/** Accessible description of the whole graph */
label?: string;
className?: string;
}
const VW = 1000;
export function NetworkGraph({ nodes, links, aspect = '16 / 10', interval = 3.6, seed = 2, interactive = true, speed = 1, autoPlay = 'in-view', controls = true, label, className }: NetworkGraphProps) {
const sid = useSceneId('kvng');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const [active, setActive] = useState<string | null>(null);
const [aw, ah] = aspect.split('/').map((n) => Number(n.trim()) || 1);
const VH = round((VW * ah) / aw);
// rings from the links, breadth first from the first node
const ring = new Map<string, number>();
if (nodes[0]) ring.set(nodes[0].id, nodes[0].ring ?? 0);
for (let pass = 0; pass < 4; pass++) {
for (const [a, b] of links) {
if (ring.has(a) && !ring.has(b)) ring.set(b, (ring.get(a) ?? 0) + 1);
if (ring.has(b) && !ring.has(a)) ring.set(a, (ring.get(b) ?? 0) + 1);
}
}
const ringOf = (n: GraphNode) => n.ring ?? ring.get(n.id) ?? 2;
const groups = new Map<number, GraphNode[]>();
nodes.forEach((n) => groups.set(ringOf(n), [...(groups.get(ringOf(n)) ?? []), n]));
const pos = new Map<string, { x: number; y: number; r: number; dist: number }>();
// Inner ring evenly around the center; outer nodes gather around the angle of their parent
const angleOf = new Map<string, number>();
const inner = groups.get(1) ?? [];
inner.forEach((n, i) => angleOf.set(n.id, -90 + (360 / Math.max(1, inner.length)) * i + (rand(seed, i + 31) - 0.5) * 10));
const outer = groups.get(2) ?? [];
const parentOf = (n: GraphNode) => links.map(([a, b]) => (a === n.id ? b : b === n.id ? a : null)).find((p) => p && angleOf.has(p)) ?? null;
const kids = new Map<string, GraphNode[]>();
outer.forEach((n) => {
const p = parentOf(n) ?? '';
kids.set(p, [...(kids.get(p) ?? []), n]);
});
const spread = Math.min(26, 300 / Math.max(1, outer.length));
let orphan = 0;
for (const [p, list] of kids) {
list.forEach((n, j) => {
const baseAngle = angleOf.get(p) ?? -90 + (360 / Math.max(1, outer.length)) * orphan++;
angleOf.set(n.id, baseAngle + (j - (list.length - 1) / 2) * spread + (rand(seed + 9, j) - 0.5) * 6);
});
}
for (const [r, list] of groups) {
list.forEach((n, i) => {
if (n.x != null && n.y != null) {
const x = (n.x / 100) * VW;
const y = (n.y / 100) * VH;
pos.set(n.id, { x, y, r, dist: Math.hypot(x - VW / 2, (y - VH / 2) * (VW / VH)) / (VW / 2) });
return;
}
if (r === 0) {
pos.set(n.id, { x: VW / 2, y: VH / 2, r, dist: 0 });
return;
}
const a = ((angleOf.get(n.id) ?? -90 + (360 / list.length) * i) * Math.PI) / 180;
const rx = (r === 1 ? 0.24 : 0.43) * VW * (1 + (rand(seed + 3, i + r * 17) - 0.5) * 0.08);
const ry = (r === 1 ? 0.27 : 0.42) * VH * (1 + (rand(seed + 5, i + r * 13) - 0.5) * 0.08);
const x = VW / 2 + Math.cos(a) * rx;
const y = VH / 2 + Math.sin(a) * ry;
pos.set(n.id, { x: round(x), y: round(y), r, dist: r === 1 ? 0.5 : 1 });
});
}
const T = round(interval / Math.max(0.1, speed), 2);
const wave = 1.5; // seconds the ripple needs from center to the edge
const css = `${SCENE_CSS}
${keyframes(`${sid}-ripple`, [[0, 'opacity:.5;transform:translate(-50%,-50%) scale(.05)'], [(wave / interval) * 100, 'opacity:0;transform:translate(-50%,-50%) scale(1)'], [100, 'opacity:0;transform:translate(-50%,-50%) scale(1)']])}
${keyframes(`${sid}-lit`, [[0, 'opacity:0'], [6, 'opacity:1'], [34, 'opacity:0'], [100, 'opacity:0']])}
${keyframes(`${sid}-glow`, [[0, 'opacity:0;transform:scale(.8)'], [5, 'opacity:.9;transform:scale(1)'], [32, 'opacity:0;transform:scale(1.5)'], [100, 'opacity:0;transform:scale(1.5)']])}
${keyframes(`${sid}-draw`, [[0, 'stroke-dashoffset:1'], [100, 'stroke-dashoffset:0']])}
${keyframes(`${sid}-pop`, [[0, 'opacity:0;transform:translate(-50%,-50%) scale(.6)'], [100, 'opacity:1;transform:translate(-50%,-50%) scale(1)']])}
.${sid}:not([data-kv="off"]) .${sid}-ripple { animation: ${sid}-ripple ${T}s cubic-bezier(.2,.6,.3,1) 1.2s infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-lit { animation: ${sid}-lit ${T}s ease-out var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-glow { animation: ${sid}-glow ${T}s ease-out var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-line { stroke-dasharray: 1 1; animation: ${sid}-draw .9s var(--ease-kit) var(--e) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-pop { animation: ${sid}-pop .6s var(--ease-kit) var(--e) 1 both; }`;
const linked = (id: string) => new Set(links.filter(([a, b]) => a === id || b === id).flatMap(([a, b]) => [a, b]));
const near = active ? linked(active) : null;
const delayOf = (dist: number) => round(1.2 + (wave * Math.min(1, dist)) * (1 / Math.max(0.1, speed)), 2);
return (
<div className={cn('relative w-full', className)}>
<div
ref={play.ref}
data-kv={play.state}
role="group"
aria-label={label}
className={cn(sid, '@container relative w-full')}
style={{ aspectRatio: aspect }}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<span
aria-hidden
className={`${sid}-ripple pointer-events-none absolute left-1/2 top-1/2 aspect-square w-[96%] rounded-full border border-[color-mix(in_oklab,var(--primary)_55%,transparent)] bg-[radial-gradient(closest-side,transparent_70%,color-mix(in_oklab,var(--primary)_12%,transparent))] opacity-0`}
/>
<svg key={play.cycle} viewBox={`0 0 ${VW} ${VH}`} className="absolute inset-0 size-full overflow-visible" aria-hidden>
{links.map(([a, b], i) => {
const p = pos.get(a);
const q = pos.get(b);
if (!p || !q) return null;
const on = near ? near.has(a) && near.has(b) && (a === active || b === active) : false;
const mid = (p.dist + q.dist) / 2;
const d = `M${p.x} ${p.y} L${q.x} ${q.y}`;
return (
<g key={i} style={{ '--e': `${round(0.1 + Math.min(p.r, q.r) * 0.25, 2)}s`, '--d': `${delayOf(mid)}s` } as React.CSSProperties}>
<path
className={`${sid}-line transition-[stroke,opacity] duration-300`}
pathLength={1}
d={d}
fill="none"
stroke={on ? 'var(--primary)' : 'color-mix(in oklab, var(--foreground) 15%, transparent)'}
strokeWidth={on ? 2 : 1.2}
opacity={near && !on ? 0.35 : 1}
vectorEffect="non-scaling-stroke"
/>
<path className={`${sid}-lit`} d={d} fill="none" stroke="var(--primary)" strokeWidth="1.8" opacity="0" vectorEffect="non-scaling-stroke" />
</g>
);
})}
</svg>
{nodes.map((n) => {
const p = pos.get(n.id);
if (!p) return null;
const center = p.r === 0;
const dim = near && !near.has(n.id);
const Tag = interactive ? 'button' : 'div';
return (
<Tag
key={n.id}
{...(interactive ? { type: 'button' as const, onFocus: () => setActive(n.id), onBlur: () => setActive(null), onPointerEnter: () => setActive(n.id), onPointerLeave: () => setActive(null), 'aria-label': n.label } : { 'aria-hidden': true })}
className={cn(`${sid}-pop group/node absolute z-10 rounded-full outline-none transition-opacity duration-300`, dim && 'opacity-40')}
style={{ left: `${round((p.x / VW) * 100, 2)}%`, top: `${round((p.y / VH) * 100, 2)}%`, transform: 'translate(-50%,-50%)', '--e': `${round(0.05 + p.r * 0.22 + rand(seed, p.x) * 0.1, 2)}s` } as React.CSSProperties}
>
<span aria-hidden className={`${sid}-glow pointer-events-none absolute -inset-1.5 rounded-full bg-[color-mix(in_oklab,var(--primary)_28%,transparent)] opacity-0 blur-[3px]`} style={{ '--d': `${delayOf(p.dist)}s` } as React.CSSProperties} />
<span
className={cn(
'relative grid place-items-center overflow-hidden rounded-full border shadow-[var(--highlight),var(--shadow-md)] transition-[transform,box-shadow,border-color] duration-300 ease-[var(--ease-kit)] group-hover/node:scale-110 group-focus-visible/node:scale-110 group-focus-visible/node:shadow-[0_0_0_3px_var(--ring)] [&_svg]:size-[46%]',
center ? 'size-16 border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-primary text-primary-foreground @max-[34rem]:size-12' : p.r === 1 ? 'size-12 border-border bg-card text-foreground @max-[34rem]:size-9' : 'size-9 border-border bg-card text-muted-foreground @max-[34rem]:size-7',
active === n.id && 'border-[var(--primary)]',
)}
>
{n.content ?? <span className="size-2 rounded-full bg-current" />}
</span>
{interactive && (
<span
aria-hidden
className="pointer-events-none absolute left-1/2 top-full z-20 mt-2 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-[11.5px] font-medium text-foreground opacity-0 shadow-[var(--shadow-md)] transition-[opacity,transform] duration-200 group-hover/node:translate-y-0 group-hover/node:opacity-100 group-focus-visible/node:translate-y-0 group-focus-visible/node:opacity-100"
>
{n.label}
</span>
)}
</Tag>
);
})}
</div>
{controls && (
<div className="mt-2 flex justify-end">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
</div>
);
}Frequent questions
How do I install Network graph?
With aiw add @ai-website/network-graph. The command puts components/ui/network-graph.tsx into your project, the code is yours from then on. It brings along motion-scene. It needs no further npm packages.
Which variants does Network graph have?
3 variants, People, Connected tools and Community.
Can an AI assistant build with Network graph?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Network graph with aiw_components and install it with aiw_add @ai-website/network-graph.