Skip to content
UI Kit
Accent color

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

3 variants

Variants

People

Avatars around a center, two rings deep. The lines draw outward when the graph appears.

Connected tools

A website in the middle and the tools around it as icons. Inner and outer ring know each other too.

Community

Thirty small members on the outer ring, ten hosts inside. Calm enough to sit behind a headline.

Details

What Network graph brings along, read from the registry and the source.

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

Props

The NetworkGraphProps interface from components/ui/network-graph.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.

  • nodesrequired
    GraphNode[]
  • linksrequired
    [string, string][]

    Pairs of node ids

  • aspectoptional
    string

    Scene ratio, width / height. Default "16 / 10".

  • intervaloptional
    number

    Seconds between two signals at speed 1. Default 3.6.

  • seedoptional
    number

    Seed for the small irregularities of the layout

  • interactiveoptional
    boolean

    Nodes take focus and show their links. Default true.

  • labeloptional
    string

    Accessible 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
components/ui/network-graph.tsx
'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.

Often searched

Jump to

Copied to the clipboard