Back to Insights

Site Notes · Interaction Design

Thinking About the Background: Node Clouds, Juice, and Random Walks

Local response, a small acknowledgment, no lattice — why the new background reads as alive instead of as wallpaper.

Dan Stativa

Building something that should feel more alive than it does?


UX Site Notes

Why the new background reacts to you instead of just sitting there

A canvas node network replaced a static graph-paper backdrop. The difference isn't a smarter effect, it's a local one — grounded in direct manipulation, game 'juice,' and unbiased random layout instead of a grid.

  • Direct manipulation: local response beats global reaction
  • Juice: feedback is what makes a system feel good, not the mechanic
  • Why an unbiased scatter reads as alive and a grid reads as dead
  • Full working code, copy-paste ready

The site used to have a graph-paper background. It looked fine and it did nothing. Here’s what replaced it, and why.

The old backdrop was repeating gradients, ruled lines, a parallax layer that drifted a few degrees when you moved the mouse. You could stare at it for an hour and learn nothing about how it felt to use the site, because it didn’t respond to you — it responded to a CSS variable that happened to be wired to your cursor.

I replaced it with a canvas: a field of faint nodes, connected by lines when they’re close enough, that brighten and tug slightly toward the pointer. Same budget of pixels. Very different feeling. Here’s why.

Responsiveness is what makes something feel real

Shneiderman’s original case for direct manipulation (1983)1 wasn’t about aesthetics. It was about trust: an interface feels real — not just described — when the thing on screen visibly reacts to your hand in real time, with no gap between action and effect. A scrollbar you drag is direct manipulation. A scrollbar you type a percentage into is not, even though both end up in the same place.

The old backdrop had a pointer variable but no real reaction — the gradient shifted, uniformly, the same way regardless of where you actually pointed. Nothing near your cursor behaved differently from anything far from it. The node cloud fixes that: proximity is the input, and only the nodes near you answer.

const pd = distTo(node.ox, node.oy, pointer.x, pointer.y);
if (pd < PULL_RADIUS) {
  pull = 1 - pd / PULL_RADIUS;   // 0 far away, 1 right under the cursor
  targetX = node.ox + ux * (MAX_PULL_PX * pull * pull);
}

Every node runs this check, every frame. Most of them fail it and stay put. That’s the whole trick — not a smarter effect, a local one.

Feedback is the fun part, not the mechanic

Game developers have a word for this: juice. Jonasson and Purho’s 2012 GDC talk, “Juice It or Lose It,”2 makes the point with a bare Asteroids clone — no new mechanics, just screen shake, particle bursts, and squash-and-stretch on every collision. Same game. Measurably more fun. The lesson generalizes past games: people don’t enjoy systems for their correctness, they enjoy them for how clearly the system acknowledges what they just did.

A node that brightens and leans toward your cursor is a tiny acknowledgment, repeated at whatever rate you move the mouse.

const alpha = 0.16 + node.pull * 0.5;   // dimmer at rest, brighter when noticed
const r = node.r + node.pull * 1.6;     // and very slightly larger

pull is the same 0-to-1 proximity value from the section above, just spent on paint instead of position. It costs nothing functionally. It’s also the entire reason the new background reads as alive where the old one read as wallpaper.

Why the layout isn’t a grid

The old backdrop was built from repeating-linear-gradient — a lattice, by construction. Lattices are legible but dead; every cell predicts the next one. The node cloud instead scatters points by unbiased chance and lets local rules (link distance, pointer proximity) decide what’s visible.

const ox = rand() * width;
const oy = rand() * height;

That’s the entire layout algorithm. No grid, no simulation, no Poisson-disc spacing to keep points apart — just a seeded PRNG (rand, a small mulberry32 generator) sampling two uniform numbers per node. The seed is derived from the viewport size, so a given screen gets the same scatter on every reload instead of reshuffling underfoot.

That’s not a literal random walk — nodes don’t take steps each frame — but it borrows the same premise, dating back to Pearson’s 1905 note in Nature3 asking where a drunkard ends up after n random steps: structure can emerge from local, memoryless rules without anyone drawing a lattice first. A field of unbiased points reads as organic precisely because nothing about it predicts its neighbor.

Put together: local response, small acknowledgment, no lattice. None of it is complicated. It just isn’t wallpaper anymore.

Full code

The actual implementation, trimmed of Astro-specific wiring, as a standalone HTML file. Paste it as-is, open it in a browser, move your mouse.

<!doctype html>
<canvas id="node-cloud" style="position:fixed;inset:0;width:100vw;height:100vh;display:block"></canvas>
<script>
(() => {
  const canvas = document.getElementById('node-cloud');
  const ctx = canvas.getContext('2d');
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  const DOT_RGB = '22, 24, 29';
  const LINE_RGB = '37, 99, 235';
  const LINK_DIST = 150;
  const POINTER_RADIUS = 240;      // brightness/size boost
  const HUB_RADIUS = POINTER_RADIUS * 0.65; // cursor-to-node "hub" lines
  const PULL_RADIUS = 170;        // positional gravitation, tighter than the glow
  const MAX_PULL_PX = 16;         // capped tug so nodes never chase the cursor
  const JITTER_AMP = 1.6;         // small wobble while highlighted
  const EASE = 0.16;              // damped lerp toward target each frame, no overshoot
  const SETTLE_EPS = 0.05;
  const DENSITY = 10667;          // px^2 per node

  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  let width = 0, height = 0, nodes = [];
  const pointer = { x: -9999, y: -9999, active: false };
  let raf = 0, running = false;

  function mulberry32(seed) {
    return () => {
      seed = (seed + 0x6d2b79f5) | 0;
      let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }

  function buildNodes() {
    const count = Math.max(36, Math.min(105, Math.round((width * height) / DENSITY)));
    const rand = mulberry32(count * 7919 + Math.round(width) + Math.round(height));
    nodes = Array.from({ length: count }, () => {
      const ox = rand() * width, oy = rand() * height;
      return { ox, oy, x: ox, y: oy, r: 1.1 + rand() * 1.5,
        phaseX: rand() * Math.PI * 2, phaseY: rand() * Math.PI * 2, pull: 0 };
    });
  }

  const distTo = (x, y, px, py) => Math.hypot(x - px, y - py);

  function step(time) {
    let unsettled = false;
    for (const node of nodes) {
      let targetX = node.ox, targetY = node.oy, pull = 0;
      if (pointer.active) {
        const pd = distTo(node.ox, node.oy, pointer.x, pointer.y);
        if (pd < PULL_RADIUS) {
          pull = 1 - pd / PULL_RADIUS;
          const ux = pd > 0.01 ? (pointer.x - node.ox) / pd : 0;
          const uy = pd > 0.01 ? (pointer.y - node.oy) / pd : 0;
          const magnitude = MAX_PULL_PX * pull * pull;
          targetX = node.ox + ux * magnitude;
          targetY = node.oy + uy * magnitude;
        }
      }
      node.pull = pull;
      if (pull > 0.01) {
        targetX += Math.sin(time * 0.0022 + node.phaseX) * JITTER_AMP * pull;
        targetY += Math.cos(time * 0.0018 + node.phaseY) * JITTER_AMP * pull;
      }
      node.x += (targetX - node.x) * EASE;
      node.y += (targetY - node.y) * EASE;
      if (Math.abs(node.x - node.ox) > SETTLE_EPS || Math.abs(node.y - node.oy) > SETTLE_EPS) unsettled = true;
    }
    return unsettled;
  }

  function draw() {
    ctx.clearRect(0, 0, width, height);

    for (let i = 0; i < nodes.length; i++) {
      const a = nodes[i];
      for (let j = i + 1; j < nodes.length; j++) {
        const b = nodes[j];
        const dist = Math.hypot(a.x - b.x, a.y - b.y);
        if (dist > LINK_DIST) continue;
        let alpha = (1 - dist / LINK_DIST) * 0.12;
        const boost = Math.max(a.pull, b.pull);
        if (boost > 0) alpha += boost * 0.35;
        if (alpha <= 0.004) continue;
        ctx.strokeStyle = `rgba(${LINE_RGB}, ${Math.min(alpha, 0.5)})`;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(a.x, a.y);
        ctx.lineTo(b.x, b.y);
        ctx.stroke();
      }
    }

    if (pointer.active) {
      for (const node of nodes) {
        const pd = distTo(node.x, node.y, pointer.x, pointer.y);
        if (pd >= HUB_RADIUS) continue;
        const alpha = (1 - pd / HUB_RADIUS) * 0.4;
        ctx.strokeStyle = `rgba(${LINE_RGB}, ${alpha})`;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(pointer.x, pointer.y);
        ctx.lineTo(node.x, node.y);
        ctx.stroke();
      }
    }

    for (const node of nodes) {
      const alpha = 0.16 + node.pull * 0.5;
      const r = node.r + node.pull * 1.6;
      ctx.beginPath();
      ctx.fillStyle = `rgba(${DOT_RGB}, ${Math.min(alpha, 0.6)})`;
      ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  function tick(time) {
    raf = 0;
    const unsettled = step(time);
    draw();
    if (pointer.active || unsettled) { running = true; raf = requestAnimationFrame(tick); }
    else running = false;
  }

  function ensureRunning() {
    if (running || raf) return;
    running = true;
    raf = requestAnimationFrame(tick);
  }

  function resize() {
    width = window.innerWidth;
    height = window.innerHeight;
    canvas.width = Math.round(width * dpr);
    canvas.height = Math.round(height * dpr);
    canvas.style.width = width + 'px';
    canvas.style.height = height + 'px';
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    buildNodes();
    draw();
  }

  resize();
  let resizeTimer = 0;
  window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(resize, 150);
  });

  if (!reducedMotion) {
    window.addEventListener('pointermove', (event) => {
      pointer.x = event.clientX;
      pointer.y = event.clientY;
      pointer.active = true;
      ensureRunning();
    }, { passive: true });
    document.addEventListener('mouseleave', () => {
      pointer.active = false;
      ensureRunning();
    });
  }
})();
</script>

Footnotes

  1. Shneiderman, B. (1983). “Direct Manipulation: A Step Beyond Programming Languages.” IEEE Computer, 16(8), 57–69. doi.org/10.1109/MC.1983.1654471

  2. Jonasson, M. & Purho, P. (2012). “Juice It or Lose It,” GDC Europe. Talk recording on YouTube; source/demo (Juicy Breakout).

  3. Pearson, K. (1905). “The Problem of the Random Walk.” Nature, 72, 294. nature.com/articles/072294b0


Dan Stativa

Building something that should feel more alive than it does?