GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Force Directed Graph: 9 Network Layout Demos

Give nodes a push-and-pull force and a tangled relationship untangles into a readable picture on its own.

Auto-plays · click a tile to jump to its section · all nine in one zip

A network graph on a page is usually a static picture exported from some other tool. This post builds the working thing nine ways: each scene computes where nodes push each other apart and where edges — or, for two items, a direct parent-and-polar coordinate instead — pull them back together, then lets that balance settle again every single frame. Nine standalone HTML files hold the whole set, each one small enough to paste into a page with nothing else installed; a bundled React version of every scene rides along in the same archive.

From Repulsion and Springs to Polar Coordinates: Recomputing the Graph Layout

All nine scenes share one core: repulsion pushes every pair of nodes apart, and something pulls related nodes back together, and the layout that falls out is whichever shape balances the two. 01 is that core alone — eighteen nodes, a random spanning tree of springs, nothing else — the item every other one branches from. 02 keeps the same physics and adds one interaction: pin a node with a pointer and watch the rest of the graph reflow around it, exactly the interactivity a force directed graph visualization is built around — you never lay a graph out twice, you just nudge it. 03 turns one number, the repulsion multiplier between groups, into a sorting operation: raise it and clusters that share nothing but a color pull apart on their own, with zero edges anywhere. 04 adds a second pass after the physics runs — a hard correction that resolves overlap by radius — because repulsion alone still lets two fast-moving nodes graze past each other for a frame or two. 05 and 06 are the odd pair out: neither runs edges or springs at all. 05 places nodes by parent[] and a radial-plus-tangential spring computed in angle space, and 06 drops springs entirely for polar coordinates — a per-ring angular velocity, a tangential separation force, and a radius that breathes on a sine wave. 07 puts a weight on every edge and lets that one number rewrite both the spring's rest length and the drawn line's width. 08 scales the same pipeline to a hundred and forty nodes inside a fixed 1400×900 world and keeps the camera — pan, zoom, or an automatic sweep — entirely separate from the physics; it is the only item where rotateAll never runs, because there is no single shared rotation to apply once a camera already owns the view. 09 is the front door: the same repulsion-and-spring core as 01, tuned stronger, holding ten labeled feature nodes instead of anonymous dots.

All nine moved on 23 of 24 sampled frames — nothing here is a static card pretending to be a demo. Painted area ranks 08 highest at 70.841%, the one scene with a big enough world and enough nodes to keep lighting new pixels every frame, then 03 (55.851%), 07 (47.947%), 01 (46.931%), 02 (39.459%), 09 (36.535%), 06 (35.355%), 05 (32.872%), and 04 lowest at 31.53% — the collision-avoidance layout stays the most compact because its correction pass never lets nodes share a patch of pixels for long. Average intensity runs the other way: 04 is brightest at 83.3, since a position correction that shoves overlapping pairs apart flips pixels sharply every frame it fires, while 08 is dimmest at 28.3, its brightness spread thin across a much larger stage.

# Item Technique Painted area Peak frame Avg intensity
01 Basic spring layout N=18, REPEL=900, k=0.02, REST=46 46.931% 10.174% 53.7
02 Drag to reflow N=14, pin-and-drag, 5.6s auto-script 39.459% 7.694% 53.7
03 Cluster coloring N=30/3 groups, intra 0.5× / inter 1.9× 55.851% 10.608% 42.1
04 Collision avoidance N=22, hard correction pass, r 6–15 31.53% 5.551% 83.3
05 Hierarchy tree 3 levels × 7, radial + parent spring 32.872% 7.752% 53.2
06 Radial orbit 4 rings × 6, per-ring ω, breathing radius 35.355% 6.037% 57.3
07 Relationship weight N=16, weight rewrites length & width 47.947% 9.865% 55.6
08 Large-scale pan/zoom N=140, world 1400×900, camera decoupled 70.841% 21.407% 28.3
09 Hero graph N=10 fixed labels, REPEL=1500 36.535% 6.272% 52.7

Code per item

What follows below comes directly out of each item's own file: the real constant names, the real order the loops run in, only the Korean comments and on-screen text swapped for English. No fragment stops mid-function — every block shown here is a complete loop, pass, or draw call exactly as it runs on the page. Download links for all nine full files, plus a React rewrite of each, sit inside the archive this article unlocks.

01Basic spring layout — repulsion and springs untangle scattered nodes alone

Give eighteen nodes a mutual push, tie a random spanning tree between them with springs, and a scattered mess of dots untangles into a readable layout on its own — 46.931% painted area, 10.174% peak frame, 53.7 average intensity. This is the bare pipeline the other eight items branch from: nothing but repulsion, springs, and a shared slow rotation.

Basic force layout — repulsion and springs alone tidy scattered dots
Open in a new tab

01 · Basic force layout — repulsion and springs alone tidy scattered dots — Measured area 46.931% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arclineTorequestAnimationFrameMath.hypot

Repulsion between every pair

    for (var i = 0; i < N; i++) {
      for (var j = i + 1; j < N; j++) {
        var dx = px[j] - px[i], dy = py[j] - py[i];
        var d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
        var d = Math.sqrt(d2);
        var f = REPEL / d2;
        var ux = dx / d, uy = dy / d;
        fx[i] -= ux * f; fy[i] -= uy * f;
        fx[j] += ux * f; fy[j] += uy * f;
      }
    }

Springs pull connected pairs toward the rest length

    for (var e = 0; e < edges.length; e++) {
      var a = edges[e][0], b = edges[e][1];
      var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
      var d3 = Math.hypot(dx2, dy2) || 0.001;
      var diff = (d3 - REST) * ATTRACT;
      var ux2 = dx2 / d3, uy2 = dy2 / d3;
      fx[a] += ux2 * diff; fy[a] += uy2 * diff;
      fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
    }

02Drag to reflow — pin one node, watch the rest of the graph rebalance

Fourteen nodes settle the same way, but grab one with a pointer and it pins in place while the rest of the graph reflows around that fixed point — 39.459% painted area, 7.694% peak frame, 53.7 average intensity. No human touch is required to see it: an auto-drag script pulls a node through three legs of its own on a 5.6-second cycle whenever no one is holding the canvas.

Drag to reflow — pull one node and the whole graph rebalances
Open in a new tab

02 · Drag to reflow — pull one node and the whole graph rebalances — Measured area 39.459% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovearclineTorequestAnimationFrame

Integration skips whichever node is pinned

    var cx = W / 2, cy = H / 2;
    for (var n = 0; n < N; n++) {
      if (n === pinnedIdx) { vx[n] = 0; vy[n] = 0; continue; }
      fx[n] += (cx - px[n]) * CENTER_K;
      fy[n] += (cy - py[n]) * CENTER_K;
      fx[n] += (rand() - 0.5) * JITTER;
      fy[n] += (rand() - 0.5) * JITTER;
      vx[n] = (vx[n] + fx[n] * dt) * DAMP;
      vy[n] = (vy[n] + fy[n] * dt) * DAMP;
      px[n] += vx[n] * dt; py[n] += vy[n] * dt;
    }

An auto-drag script keeps the demo alive with no pointer at all

  // .is-demo auto-drag script — even with no human press, the screen moves through the same pinnedIdx path
  var script = [
    { t: 0.3, dur: 1.3, from: { x: 0.22, y: 0.5 }, to: { x: 0.78, y: 0.22 } },
    { t: 2.1, dur: 1.3, from: { x: 0.78, y: 0.7 }, to: { x: 0.24, y: 0.82 } },
    { t: 3.9, dur: 1.3, from: { x: 0.5, y: 0.18 }, to: { x: 0.62, y: 0.86 } }
  ];
  var AUTO_CYCLE = 5.6;

  function findEntry(tMod) {
    for (var i = 0; i < script.length; i++) {
      var s = script[i];
      if (tMod >= s.t && tMod <= s.t + s.dur) return s;
    }
    return null;
  }

03Cluster coloring — a repulsion multiplier huddles same-color nodes together

Thirty nodes split across three groups where same-group repulsion is weak (0.5×) and cross-group repulsion is strong (1.9×), and every frame recomputes each group's centroid to pull its own members home — 55.851% painted area, 10.608% peak frame, 42.1 average intensity. There isn't a single edge or spring anywhere in this item; color and a repulsion multiplier are the entire sorting mechanism.

Cluster coloring — same-group pulls stronger and huddles together
Open in a new tab

03 · Cluster coloring — same-group pulls stronger and huddles together — Measured area 55.851% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arcfillRectrequestAnimationFrameMath.hypot

Repulsion multiplies by whether two nodes share a group

    for (var i = 0; i < N; i++) {
      for (var j = i + 1; j < N; j++) {
        var dx = px[j] - px[i], dy = py[j] - py[i];
        var d = Math.hypot(dx, dy) || 0.001;
        var d2 = d * d; if (d2 < 4) d2 = 4;
        var mult = grp[i] === grp[j] ? INTRA_REPEL : INTER_REPEL;
        var f = (REPEL * mult) / d2;
        var ux = dx / d, uy = dy / d;
        fx[i] -= ux * f; fy[i] -= uy * f;
        fx[j] += ux * f; fy[j] += uy * f;
      }
    }

Group centroids are recomputed every frame and pull their members home

    // group centroid — recomputed every frame, pulls members gently toward it
    var cxg = new Float32Array(GROUPS), cyg = new Float32Array(GROUPS), cnt = new Float32Array(GROUPS);
    for (var n = 0; n < N; n++) { cxg[grp[n]] += px[n]; cyg[grp[n]] += py[n]; cnt[grp[n]]++; }
    for (var g = 0; g < GROUPS; g++) { if (cnt[g] > 0) { cxg[g] /= cnt[g]; cyg[g] /= cnt[g]; } }

    var cx = W / 2, cy = H / 2;
    for (var m = 0; m < N; m++) {
      fx[m] += (cxg[grp[m]] - px[m]) * CENTROID_K;
      fy[m] += (cyg[grp[m]] - py[m]) * CENTROID_K;
      fx[m] += (cx - px[m]) * CENTER_K;
      fy[m] += (cy - py[m]) * CENTER_K;
      vx[m] = (vx[m] + fx[m] * dt) * DAMP;
      vy[m] = (vy[m] + fy[m] * dt) * DAMP;
      px[m] += vx[m] * dt; py[m] += vy[m] * dt;
    }

04Collision avoidance — a correction pass keeps node circles from ever overlapping

Twenty-two nodes drift on their own random heading, and a second pass run right after force integration pushes any overlapping pair apart by exactly half the gap between their two radii — 31.53% painted area, 5.551% peak frame, 83.3 average intensity, the brightest and the most compact of the nine. Repulsion alone still lets nodes graze past each other for a frame; the hard correction pass is what actually stops the overlap.

Collision avoidance — nodes push apart by their own radius, never overlap
Open in a new tab

04 · Collision avoidance — nodes push apart by their own radius, never overlap — Measured area 31.53% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arcMath.hypotrequestAnimationFramebeginPath

Free drift under repulsion and a wandering push

    for (var i = 0; i < N; i++) {
      for (var j = i + 1; j < N; j++) {
        var dx = px[j] - px[i], dy = py[j] - py[i];
        var d = Math.hypot(dx, dy) || 0.001;
        var d2 = d * d; if (d2 < 4) d2 = 4;
        var f = REPEL / d2;
        var ux = dx / d, uy = dy / d;
        fx[i] -= ux * f; fy[i] -= uy * f;
        fx[j] += ux * f; fy[j] += uy * f;
      }
    }

A hard correction pass pushes overlapping pairs apart by their radii

    // hard collision resolution — pairs closer than the sum of their radii get pushed apart, split evenly
    for (var a = 0; a < N; a++) {
      for (var b = a + 1; b < N; b++) {
        var ddx = px[b] - px[a], ddy = py[b] - py[a];
        var dist = Math.hypot(ddx, ddy) || 0.001;
        var minDist = rad[a] + rad[b];
        if (dist < minDist) {
          var push = (minDist - dist) / 2;
          var nx = ddx / dist, ny = ddy / dist;
          px[a] -= nx * push; py[a] -= ny * push;
          px[b] += nx * push; py[b] += ny * push;
        }
      }
    }

05Hierarchy tree — levels radiate outward from a root by angle, not by edge

Twenty-two nodes fan out from a single root across three levels, each held at its own level's target radius by a radial spring and toward its parent by a tangential one, with same-level nodes nudged apart in angle space so the ring never crowds — 32.872% painted area, 7.752% peak frame, 53.2 average intensity. There is no edges array here at all: parent[] and Math.atan2 place every node directly.

Hierarchy tree — levels radiating out from a single root
Open in a new tab

05 · Hierarchy tree — levels radiating out from a single root — Measured area 32.872% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

lineToarcrequestAnimationFrameMath.atan2

Radial and parent springs place each node by level, not by an edge

    for (var i = 1; i < N; i++) {
      var dx = px[i] - cx, dy = py[i] - cy;
      rd[i] = Math.hypot(dx, dy) || 0.001;
      ang[i] = Math.atan2(dy, dx);
      var targetR = level[i] * RSTEP;
      var diff = (rd[i] - targetR) * RADIAL_K;
      var ux = dx / rd[i], uy = dy / rd[i];
      fx[i] -= ux * diff; fy[i] -= uy * diff;
      // weak spring toward the parent — keeps the tree shape
      var pdx = px[parent[i]] - px[i], pdy = py[parent[i]] - py[i];
      fx[i] += pdx * PARENT_K; fy[i] += pdy * PARENT_K;
    }

Same-level nodes push apart along the tangent so the ring never crowds

    for (var a = 1; a < N; a++) {
      for (var b = a + 1; b < N; b++) {
        if (level[a] !== level[b]) continue;
        var da = ang[a] - ang[b];
        while (da > Math.PI) da -= Math.PI * 2;
        while (da < -Math.PI) da += Math.PI * 2;
        if (Math.abs(da) < 0.001) da = 0.02;
        var strength = TANGENT_K / (Math.abs(da) * rd[a] + 8);
        var sign = da > 0 ? 1 : -1;
        // tangent direction (perpendicular to the radius)
        var tx = -Math.sin(ang[a]), ty = Math.cos(ang[a]);
        fx[a] += tx * strength * sign; fy[a] += ty * strength * sign;
        var tx2 = -Math.sin(ang[b]), ty2 = Math.cos(ang[b]);
        fx[b] -= tx2 * strength * sign; fy[b] -= ty2 * strength * sign;
      }
    }

06Radial orbit — concentric rings spin at their own speed while nodes settle

Twenty-four nodes sit on four concentric rings, each ring spinning at its own angular velocity — some clockwise, some the opposite way — while same-ring neighbors keep a minimum angular gap and every radius breathes in and out on a sine wave — 35.355% painted area, 6.037% peak frame, 57.3 average intensity. Polar coordinates place every node each frame; this is the one item with no force-directed physics running at all.

Radial orbit — nodes settle while orbiting concentric levels
Open in a new tab

06 · Radial orbit — nodes settle while orbiting concentric levels — Measured area 35.355% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arcMath.sinMath.cosrequestAnimationFrame

Each ring spins at its own angular velocity

    for (var i = 0; i < N; i++) {
      angle[i] += RING_OMEGA[ring[i]] * dt;
    }
    // same-ring neighbors keep a minimum angular gap (tangential push, corrected directly in angle space)
    for (var a = 0; a < N; a++) {
      for (var b = a + 1; b < N; b++) {
        if (ring[a] !== ring[b]) continue;
        var da = angle[a] - angle[b];
        while (da > Math.PI) da -= Math.PI * 2;
        while (da < -Math.PI) da += Math.PI * 2;
        var minGap = (Math.PI * 2 / PER_RING) * 0.7;
        if (Math.abs(da) < minGap && Math.abs(da) > 0.0001) {
          var push = (minGap - Math.abs(da)) * SEP_K * dt;
          var sign = da > 0 ? 1 : -1;
          angle[a] += push * sign;
          angle[b] -= push * sign;
        }
      }
    }

Radius breathes on a sine wave as polar coordinates place every node

    var cx = W / 2, cy = H / 2;
    for (var n = 0; n < N; n++) {
      var baseR = (ring[n] + 1) * RSTEP;
      var rr = baseR + Math.sin(t * 0.7 + n * 0.6) * BREATHE;
      px[n] = cx + Math.cos(angle[n]) * rr;
      py[n] = cy + Math.sin(angle[n]) * rr;
    }

07Relationship weight — one number rewrites a link's length and its line width

Sixteen nodes connect over weighted edges, and a heavier weight both shortens the spring's target length and stiffens its pull, drawing the link thicker and closer the stronger the tie gets — 47.947% painted area, 9.865% peak frame, 55.6 average intensity. Edge weight becomes something you can actually see: the more important the relationship, the tighter and bolder its line renders.

Relationship weight — weight rewrites link length and thickness
Open in a new tab

07 · Relationship weight — weight rewrites link length and thickness — Measured area 47.947% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

lineTostrokeStyleglobalAlpharequestAnimationFrame

A heavier edge weight shortens the spring's rest length and stiffens it

    for (var e = 0; e < edges.length; e++) {
      var ed = edges[e], a = ed.a, b = ed.b;
      // the stronger the relationship (higher weight), the shorter the target distance
      var rest = LINK_BASE * (1.5 - ed.w);
      var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
      var d3 = Math.hypot(dx2, dy2) || 0.001;
      var diff = (d3 - rest) * ATTRACT * (0.5 + ed.w);
      var ux2 = dx2 / d3, uy2 = dy2 / d3;
      fx[a] += ux2 * diff; fy[a] += uy2 * diff;
      fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
    }

The same weight sets each edge's line width and opacity when drawn

    for (var e = 0; e < edges.length; e++) {
      var ed = edges[e];
      ctx.lineWidth = 0.6 + ed.w * 3.2;
      ctx.strokeStyle = 'rgb(' + C_EDGE.join(',') + ')';
      ctx.globalAlpha = 0.2 + ed.w * 0.65;
      ctx.beginPath();
      ctx.moveTo(px[ed.a], py[ed.a]);
      ctx.lineTo(px[ed.b], py[ed.b]);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;

08Large-scale pan and zoom — a camera sweeps hundreds of nodes without touching the physics

A hundred and forty nodes settle inside a fixed 1400×900 world, repulsion skipping any pair more than 200px apart to keep the O(n²) loop affordable, while the camera alone pans and zooms across that world on wheel, drag, or an automatic sweep — 70.841% painted area (the highest of the nine), 21.407% peak frame (also the highest), 28.3 average intensity (the lowest, spread thin across so much more of the frame). Physics never touches the camera transform: every force is computed in world coordinates before ctx.translate and ctx.scale ever run.

Large-scale pan/zoom — sweeping hundreds of nodes across canvas coordinates
Open in a new tab

08 · Large-scale pan/zoom — sweeping hundreds of nodes across canvas coordinates — Measured area 70.841% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramearclineToMath.hypot

Repulsion skips far pairs, computed entirely in world space

    for (var i = 0; i < N; i++) {
      for (var j = i + 1; j < N; j++) {
        var dx = px[j] - px[i], dy = py[j] - py[i];
        var d2 = dx * dx + dy * dy; if (d2 < 9) d2 = 9;
        if (d2 > 40000) continue;   // far pairs are skipped — protects performance on a large graph
        var d = Math.sqrt(d2);
        var f = REPEL / d2;
        var ux = dx / d, uy = dy / d;
        fx[i] -= ux * f; fy[i] -= uy * f;
        fx[j] += ux * f; fy[j] += uy * f;
      }
    }

The camera transform is the only thing that ever moves the view

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = 'rgb(' + C_PANEL.join(',') + ')';
    ctx.fillRect(0, 0, W, H);

    ctx.save();
    ctx.translate(W / 2, H / 2);
    ctx.scale(zoom, zoom);
    ctx.translate(-camX, -camY);

09Hero graph — a front door that knits a product's own features together by force

Ten fixed-label nodes — Login, Payment, Alerts, Search, Chat, Files, Settings, Stats, Dashboard, Integrations — settle under the same repulsion-and-spring pipeline as item 01, tuned stronger (REPEL=1500) to hold a wider hero-banner spread — 36.535% painted area, 6.272% peak frame, 52.7 average intensity. This is the front door: the same nine-item pipeline, pointed at a product's own feature names instead of anonymous dots.

Hero graph — a front door that knits a product's features together by force
Open in a new tab

09 · Hero graph — a front door that knits a product's features together by force — Measured area 36.535% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

arclineTorequestAnimationFramecreateRadialGradient

The same physics as item 01, tuned stronger, driving the frame loop

  function frame(now) {
    if (t0 < 0) { t0 = now; last = now; }
    var dt = Math.min((now - last) / 1000, 0.05); last = now;
    var t = (now - t0) / 1000;
    step(dt);
    rotateAll(OMEGA * dt);
    draw(t);
    requestAnimationFrame(frame);
  }

Glow, node, and label render together for each of the ten features

    ctx.font = '600 10px sans-serif';
    ctx.textAlign = 'center';
    for (var i = 0; i < N; i++) {
      var pulse = 0.5 + 0.5 * Math.sin(t * 2 + i * 0.65);
      var glowR = NR + 10 + pulse * 8;
      var grad = ctx.createRadialGradient(px[i], py[i], 0, px[i], py[i], glowR);
      grad.addColorStop(0, 'rgba(' + C_GLOW.join(',') + ',' + (0.5 + 0.3 * pulse).toFixed(3) + ')');
      grad.addColorStop(1, 'rgba(' + C_GLOW.join(',') + ',0)');
      ctx.fillStyle = grad;
      ctx.beginPath(); ctx.arc(px[i], py[i], glowR, 0, Math.PI * 2); ctx.fill();

      ctx.fillStyle = 'rgb(' + C_NODE.join(',') + ')';
      ctx.beginPath(); ctx.arc(px[i], py[i], NR, 0, Math.PI * 2); ctx.fill();

      ctx.fillStyle = 'rgb(' + C_TEXT.join(',') + ')';
      ctx.fillText(LABELS[i], px[i], py[i] - NR - 6);
    }

Why a couple of forces alone tidy a relationship map — repulsion and springs

Two forces, nothing else, and a layout appears

Every scene in this set stands on the same two terms: a repulsion that pushes every pair of nodes apart, falling off with distance, and something that pulls related nodes back together — a spring along an edge in seven of the nine items, or a radial/tangential pull computed straight from coordinates in the other two. Sum both into a force each frame, add it to velocity, damp the velocity so energy bleeds off, then move the position — and a graph that started as a scatter of random points resolves into a spread where connected things sit close and unconnected things sit apart. Nothing decides the final shape ahead of time; it falls out of the balance. This is the same idea a full network graph visualization library packages behind a bigger API — here it's laid bare in under a hundred lines per item, the same way this site works out the thread-and-pin layout that recomputes chord brightness every frame and an organism that lays down its own shortest-path network as it feeds from a handful of rules instead of a drawn picture.

rotateAll keeps a rigid rotation from ever fighting the physics

rotateAll is a rotation about one shared center by one shared angle, and it runs after step() finishes each frame, never before or during. Because it rotates every node by the exact same angle around the exact same point, it changes no distance and no angle between any pair of nodes — the force math has no idea the rotation happened, and the rotation never has to know what the forces computed. That separation is what lets a fully converged, motionless layout keep sweeping slowly across the screen forever without the physics re-triggering: items 01 through 05, 07, and 09 all call it this way, each with its own OMEGA.

Not every item spins, and two skip force entirely

06 and 08 both leave rotateAll out. 06 already spins each of its four rings at its own RING_OMEGA — a rotation baked directly into the polar placement, so a second shared rotation on top would be redundant. 08 hands rotation-like movement to the camera instead: the world of 140 nodes never turns, but panning and zooming make the view feel like it does, and mixing a rigid rotateAll into a scene that's also being panned and zoomed would double up on motion the camera already owns. 05 and 06 go a step further and skip springs and edges altogether — 05 computes a radial spring and a parent-directed pull straight from parent[] and Math.atan2, and 06 computes position directly from polar coordinates every frame — so "force-directed" describes seven of the nine items literally and the other two only in spirit, by the same repulsion-and-pull principle applied to angles instead of edges.

Three knobs turn into a settled layout and a reduced-motion freeze

Three knobs to turn

Name Change Feel
Attraction strength item 01 spring k 0.02 → 0.08 the same eighteen nodes snap into shape faster, but the stronger pull also overshoots and oscillates wider before it settles
Node count item 01's eighteen-node base grown toward eighty more nodes take longer to settle and the O(n²) repulsion cost climbs with the square of the count, so the layout visibly slows down finding its balance
Link target distance item 07 LINK_BASE 60px → 160px the whole graph spreads out at once, and labels that used to overlap at close range finally get room to breathe

Reduced motion freezes a settled layout, never a scattered one

Every scene checks prefers-reduced-motion before its loop ever starts, and when the preference is set the loop never runs at all: instead, step() is pre-run 200 to 260 times in a tight loop (260 for most items, 200 for 02, 06, and 08) so the graph reaches its converged layout, then the canvas freezes there and the root takes an is-still class. What a reduced-motion visitor sees is never the initial random scatter — it's the same readable, settled picture everyone else eventually watches the physics arrive at, just held still from the first paint.

One item takes a pointer, one takes wheel and drag, the rest run themselves — and stay reachable without either

Item 02 listens for real pointer drag when a person provides it, falling back to its auto-drag script otherwise; item 08 does the same with wheel and drag input for its camera. The other seven run purely on requestAnimationFrame with no interaction at all. Every canvas host carries a human-readable aria-label and a tabindex regardless of which family it belongs to, so a keyboard or screen-reader visitor can still reach and identify each node graph visualization, and each item reports its own measurement meta — node count, key constants — in one line under the canvas.

Rewriting a node's move as a CSS transition is where this breaks

CSS transitions and @keyframes never touch these nodes

The whole premise here is recomputing coordinates from a summed force every single frame. Swap a node's movement for a CSS transition or an @keyframes rule and repulsion and springs vanish along with it — what's left is a property tween that happens to look similar for one cycle and then repeats identically forever, none of it responsive to the graph's actual shape. Position has to come out of step(), every frame, or it isn't a force layout anymore.

A purely random edge list can strand nodes with no path back to the rest

Every item that uses edges builds a spanning tree first — edges.push([a, Math.floor(rand() * a)]) for a running 1 to N-1 guarantees every node has at least one path to every other — and only then adds a handful of random extra edges on top (item 01 adds Math.round(N * 0.5) more). Skip the spanning tree and generate edges purely at random instead, and there's a real chance some node, or some whole cluster, ends up with no edge into the rest of the graph at all — repulsion pushes it away with nothing to pull it back, and it drifts off into open space alone.

Drop the jitter term and the layout can freeze mid-tangle

JITTER is a small random force added every frame regardless of whether anything else is happening — a constant "temperature" that never turns off. Once the springs and repulsion settle into balance, velocity converges toward zero and, without jitter, the whole scene would simply stop, frozen in whatever shape it happened to reach, crossed edges and all. The random push is what keeps enough energy in the system for the layout to keep working itself loose even after it looks basically done.

rotateAll has to run after step(), with one shared angle for every node

Call rotateAll before that frame's step(), and the force math ends up operating on coordinates that already got rotated out from under it, skewing the physics against the previous frame's layout. Give different nodes different rotation angles instead of one shared angle for all of them, and every spring's measured length changes — the rigid-body guarantee that makes rotateAll invisible to the physics only holds when the angle and the center are the same for the whole scene, every time.

Item 04's collisions need a correction pass, not a bigger repulsion number

Repulsion is continuous and its force grows only as two nodes get close — it never actually stops two centers from crossing within a single frame, no matter how large REPEL gets. That's why item 04 runs a second pass after the force integration: find any pair closer together than the sum of their two radii, and push each one apart by exactly half the overlap. Raising REPEL instead just makes the layout twitchier without ever fully closing the gap.

05 and 06 both need a tangential push or every sibling piles onto one angle

A radial spring only fixes how far a node sits from its center — it says nothing about which direction. Leave that unconstrained in 05 or 06 and every node at the same level, or on the same ring, drifts toward the same angle, because nothing is telling them apart in that dimension. The tangential separation force in both items works directly in angle space — pushing same-level or same-ring neighbors apart along the circle rather than through px/py — which is what turns a pile-up into an even ring.

Item 08's forces must live in world coordinates, before the camera ever touches them

08 always computes repulsion and springs inside the same fixed WORLD_W×WORLD_H space, and only the drawing step applies ctx.translate and ctx.scale for the camera's pan and zoom. Compute the physics after that transform instead — on screen coordinates that already carry the current zoom — and the effective strength of every force changes every time someone zooms in or out, because the same REPEL constant now covers a different amount of world space. Keeping force and camera in separate coordinate systems is what lets one repulsion constant behave consistently at any zoom level.

A seeded PRNG keeps the same layout reproducible on every load

Every item builds its own makeRand(seed) generator instead of reaching for Math.random() — item 01 seeds with 0x327001, item 05 with 0x327005, and so on. Swap in Math.random() and the graph would come out different on every reload: a different spanning tree, different starting positions, a different settled shape. That seeded generator is what makes a screenshot, a bug report, or a measurement describable — the exact same graph reproduces exactly, every single run. This set's nine complete files, seeds and all, sit in the archive this article guards; type in 6bpjfdyj and they're yours.

Where this actually gets used — an org chart and a recommendation web

The first place these nine scenes tend to land is a hero section like item 09's graph, knitting a product's own features together in front of a headline while the rest of the page loads behind it. Drop item 02 in next to it and a visitor can drag a single node and watch the whole map rebalance around their hand, which turns a static diagram into something worth pausing on.

Org charts and team pages fit item 05 better. Levels radiate outward from a root by angle rather than by edge, so reporting lines read off the layout itself, and pairing it with item 03's cluster coloring groups people by department without a single line of explanatory text.

Questions before you wire up a force layout

Can I use these force directed graph examples in a personal or commercial project

Yes, without restriction. Each of the nine runs from a single HTML file — canvas 2D, Math.hypot and Math.atan2 doing the geometry, requestAnimationFrame driving the loop, nothing pulled in from a CDN or D3-force or WebGL. Drop any one straight into a page and it behaves exactly like the live demo above it, and the archive behind this post throws in a React build of every item for anyone building with components.

Why do my nodes fly off screen or spin out of control

Almost always one of three constants. DAMP too close to 1 lets velocity keep almost all of its energy every frame, so any imbalance compounds instead of settling — every item that damps velocity here (all but the polar-coordinate 06) sits somewhere between 0.93 and 0.965. REPEL set far too high relative to the canvas size can push nodes out past the edges faster than a spring or a centering pull can recover them. And a missing centering force (CENTER_K in most of these items) leaves the whole graph free to drift anywhere at all, since nothing is telling it where "the middle" is supposed to be.

How is this different from D3's force simulation

The physics is the same idea — forceManyBody for repulsion, forceLink for springs, a velocity Verlet-style integration underneath — but D3 wraps all of it behind an API you configure and never have to read. Every line of force math in these nine items is visible and editable in the same file that draws the canvas, so changing how a force directed graph visualization behaves means editing REPEL or ATTRACT directly rather than learning a simulation object's method chain. Reach for D3 when a project already depends on it for other things; reach for one of these files when the whole point is understanding, or owning, every line that moves a node.

Sources: MDN — CanvasRenderingContext2D.arc() · MDN — CanvasRenderingContext2D.lineTo() · MDN — CanvasRenderingContext2D.beginPath() · MDN — Window.requestAnimationFrame() · MDN — Math.hypot() · Wikipedia — Force-directed graph drawing

Enter the archive password

The password is inside this article. You will find it as you read.