Front-end · 3D & Canvas

Building a Cyberpunk FUI Dashboard: A 3D Fibonacci Sphere in Vanilla JS

Build a sci-fi "fictional user interface" that maps 30 network nodes onto a rotating 3D sphere, links them with a glowing neighbor mesh, and wraps it all in CRT scanlines, glass HUD panels, and glitch type. No WebGL and no 3D library: just Canvas 2D, a handful of DOM nodes, and a little trigonometry.

A FUI (fictional user interface) is the kind of screen you see in sci-fi films: dense telemetry, neon on near-black, things that rotate and pulse. They are fun to build and a great way to practice real front-end skills like 3D projection, layered rendering, and animation loops. This walkthrough builds a "Global Node Map": 30 network nodes in cities around the world, spread across a rotating 3D sphere, with a searchable node list beside it. Click a node and the sphere spins to lock onto it.

Live Demo

Drag to spin the sphere, scroll over it to zoom, and click any node on the sphere or in the list to lock onto it.

Drag · Scroll to zoom · Click a node Open full screen →

1. The Design Language

A FUI only feels "real" when it is consistent. Three rules carry most of the look:

  • Near-black base, three neon accents, each with a job. Accents that mean something read as a system; accents used at random read as noise.
  • Two typefaces. JetBrains Mono for data (every digit lines up) and Orbitron for headings.
  • Machine-style labels. TARGET_LOCK // SQL, YAW_RAD, SYNC_OK. Upper snake case with // separators instantly says "system readout".
#00f5ffAqua: data, telemetry
#bd00ffPurple: structure, depth
#bfff00Lime: selected / active
#04060cBase background

2. A Layered Architecture

The key decision is what to draw where. Lines are cheap on a canvas but text is not crisp or clickable there. DOM elements are perfect for text and clicks, but you don't want hundreds of them for lines. So the dashboard stacks layers, each doing what it is best at:

LayerTechWhat it draws
z-0Canvas 2DPerspective grid, torus wireframe, core glow, neighbor mesh, data packets, target reticle
z-10SVG + CSSSlow counter-rotating radar rings (pure CSS animation, zero JS)
z-20DOM30 node labels: crisp text, hover, click
z-30DOMGlass HUD panels, control deck, node list
z-999CSSCRT scanlines, vignette, sweeping scan beam (pointer-events: none)

The page skeleton uses Tailwind (via its CDN script) for layout: a 2/3 viewport and a 1/3 node list side by side on desktop, stacked on phones.

HTML
<body class="flex flex-col lg:flex-row w-screen h-screen overflow-hidden bg-[#04060c]">

  <!-- Full-screen atmosphere layers (never block clicks) -->
  <div class="crt-overlay"></div>
  <div class="scanbeam"></div>

  <!-- LEFT 2/3: the 3D viewport -->
  <section id="viewport" class="relative w-full lg:w-2/3 h-[58vh] lg:h-full overflow-hidden">
    <canvas id="fui-canvas" class="absolute inset-0 w-full h-full pointer-events-none z-0"></canvas>
    <div class="absolute inset-0 pointer-events-none z-10"><!-- SVG radar rings --></div>
    <div id="sphere-stage" class="absolute inset-0 pointer-events-none z-20"><!-- 30 node labels --></div>
    <!-- HUD panels + control deck at z-30 -->
  </section>

  <!-- RIGHT 1/3: the node list -->
  <aside class="w-full lg:w-1/3 h-[42vh] lg:h-full p-3 lg:p-5 z-30 flex flex-col">
    <div class="fui-glass flex-1 rounded-2xl flex flex-col overflow-hidden">
      <div class="h-1 w-full neon-border-flow"></div>
      <div id="ledger-list" class="flex-1 overflow-y-auto p-3 space-y-2"></div>
    </div>
  </aside>
</body>

3. Atmosphere in Pure CSS

The "screen" feel costs almost nothing. The CRT effect is two stacked backgrounds: a 3px repeating gradient for scanlines and a radial gradient for the vignette. A second, very faint gradient band sweeps down the screen on a 9-second loop. Both sit on top of everything with pointer-events: none, so they never block a click.

CSS
:root {
  --aqua:   #00f5ff;   /* data & telemetry   */
  --purple: #bd00ff;   /* structure & depth  */
  --lime:   #bfff00;   /* selection / active */
  --bg-dark: #04060c;
}

/* CRT: 3px scanlines + a vignette, layered as two backgrounds */
.crt-overlay {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 999;
  background:
    linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.28) 50%),
    radial-gradient(circle at 33% 50%, transparent 55%, rgba(2, 4, 9, 0.85) 100%);
  background-size: 100% 3px, 100% 100%;
  opacity: 0.75;
}

/* A faint beam that sweeps down the screen every 9 seconds */
.scanbeam {
  position: fixed;
  left: 0; right: 0;
  height: 120px;
  background: linear-gradient(180deg, transparent, rgba(0, 245, 255, 0.04), rgba(189, 0, 255, 0.03), transparent);
  animation: scanlineMove 9s linear infinite;
  pointer-events: none;
  z-index: 998;
}
@keyframes scanlineMove { 0% { top: -140px; } 100% { top: 100vh; } }

/* Glass HUD panels */
.fui-glass {
  background: linear-gradient(155deg, rgba(8, 14, 28, 0.88) 0%, rgba(15, 7, 28, 0.84) 60%, rgba(5, 16, 24, 0.9) 100%);
  backdrop-filter: blur(16px);
  border: 1px solid rgba(0, 245, 255, 0.28);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.75), inset 0 0 25px rgba(0, 245, 255, 0.06);
}

The glitch title

The title is drawn three times: the real text plus two pseudo-element copies (read from a data-text attribute) offset by 2px and tinted lime and purple. Their clip-path keeps them invisible 84% of the time, then flashes thin horizontal slices for a moment. That rare, brief glitch reads as "malfunction" far better than constant jitter.

HTML + CSS
<h1 class="glitch-title" data-text="NODE MAP">NODE MAP</h1>

.glitch-title { position: relative; text-shadow: 0 0 12px rgba(0, 245, 255, 0.6); }

/* Two copies of the text, pulled from data-text */
.glitch-title::before,
.glitch-title::after {
  content: attr(data-text);
  position: absolute;
  top: 0; left: 0; width: 100%; height: 100%;
  pointer-events: none;
}
.glitch-title::before { left:  2px; text-shadow: -1px 0 var(--lime);   animation: glitchSlice 4s   infinite linear alternate-reverse; }
.glitch-title::after  { left: -2px; text-shadow:  1px 0 var(--purple); animation: glitchSlice 2.7s infinite linear alternate; }

/* Hidden 84% of the time, then a few fast, offset slices */
@keyframes glitchSlice {
  0%, 84% { clip-path: inset(50% 0 50% 0); transform: translate(0, 0); }
  86%     { clip-path: inset(12% 0 68% 0); transform: translate(-2px, 1px); }
  90%     { clip-path: inset(72% 0 8% 0);  transform: translate(2px, -1px); }
  94%     { clip-path: inset(30% 0 42% 0); transform: translate(-1px, 0); }
  100%    { clip-path: inset(50% 0 50% 0); transform: translate(0, 0); }
}

4. The Fibonacci Sphere

How do you spread 30 points evenly over a sphere? Latitude/longitude grids bunch points up at the poles. The Fibonacci sphere fixes that with two simple ideas:

  1. Step the height y evenly from +1 (top) to -1 (bottom). On a sphere, equal slices of height have equal surface area, so this alone keeps the density even from top to bottom.
  2. Rotate each point around the vertical axis by a fixed golden-ratio step. Because the golden ratio is "the most irrational number", the points never line up into visible stripes. It's the same trick sunflowers use to pack their seeds.
JavaScript
const TOTAL_NODES = 30;
const goldenRatio = (1 + Math.sqrt(5)) / 2;

for (let i = 0; i < TOTAL_NODES; i++) {
  // 1. Walk y evenly from the top pole (+1) to the bottom pole (-1)
  const uy = 1 - (i / (TOTAL_NODES - 1)) * 2;

  // 2. Radius of the horizontal "slice" of the unit sphere at that height
  const radiusAtY = Math.sqrt(Math.max(0, 1 - uy * uy));

  // 3. Spin each point by a golden-ratio step around the y axis
  const theta = (2 * Math.PI * i) / goldenRatio;

  const ux = Math.cos(theta) * radiusAtY;
  const uz = Math.sin(theta) * radiusAtY;

  nodes.push({ index: i, ux, uy, uz /* + code, city, status, ping, load */ });
}

The step 2π / φ is about 222.5°, which is the famous 137.5° golden angle measured the other way around the circle. Either gives the same spiral, just mirrored. Each point is stored as a unit vector (length 1), so you can scale the sphere to any screen size later by multiplying by a radius.

5. Rotation and Perspective

Every frame, each point is rotated by the current camera angles and then projected to the screen. Rotation is the classic 2D rotation formula applied twice: once around the vertical Y axis (yaw, spinning left/right) and once around the horizontal X axis (pitch, tilting up/down).

Projection is a single perspective divide. Points closer to the camera (negative z) get divided by a smaller depth, so they spread out and grow. Points further away shrink toward the center. The Math.max(120, ...) clamp stops anything from blowing up if it gets too close to the camera.

JavaScript
// Rotate around Y (yaw), then around X (pitch)
function rotate3D(x, y, z, yaw, pitch) {
  const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
  const x1 = x * cosY - z * sinY;
  const z1 = z * cosY + x * sinY;

  const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
  const y2 = y * cosP - z1 * sinP;
  const z2 = z1 * cosP + y * sinP;

  return { x: x1, y: y2, z: z2 };
}

// Perspective divide: points with negative z are closer and get bigger
function project3D(rx, ry, rz, fov, cx, cy) {
  const depth = Math.max(120, fov + rz);   // clamp so nothing explodes near the camera
  const scale = fov / depth;
  return { sx: cx + rx * scale, sy: cy + ry * scale, scale };
}

6. Billboarded Labels: Readable From Every Angle

If you rotated the labels with the sphere (for example with CSS rotate3d), text on the far side would appear mirrored and text near the poles would be sideways. Instead, each label is a billboard: only its position and size follow the 3D math, and it always faces the viewer, perfectly horizontal.

Keep the labels short. A sphere full of long IDs turns into visual noise fast. Each node shows only a 3-letter city code (TYO, LDN, PIT); the city name, status, ping and load live in the side list and the target-lock inspector, where there is room to read them.

Depth is then sold with three cheap cues: labels at the back fade out, blur slightly, and sort behind the front ones via z-index. Because the code only changes transform, opacity and filter, the browser can composite it on the GPU without re-running layout.

JavaScript
nodes.forEach((q) => {
  const r = rotate3D(q.ux * baseRadius, q.uy * baseRadius, q.uz * baseRadius, rotYaw, rotPitch);
  const p = project3D(r.x, r.y, r.z, fov, cx, cy);
  q.rz = r.z;

  // 0 = far side of the sphere, 1 = nearest the camera
  const normDepth = Math.max(0, Math.min(1, (baseRadius - r.z) / (2 * baseRadius)));

  // Only translate + scale. The label never rotates, so it stays readable.
  q.domNode.style.transform =
    `translate3d(${p.sx.toFixed(1)}px, ${p.sy.toFixed(1)}px, 0) scale(${p.scale.toFixed(3)})`;

  // Depth cues: fade, blur and sort the back of the sphere
  const alpha  = 0.22 + Math.pow(normDepth, 1.45) * 0.78;
  const blurPx = normDepth < 0.36 ? ((0.36 - normDepth) * 4.5).toFixed(1) : 0;
  q.domNode.style.opacity = alpha.toFixed(2);
  q.domNode.style.filter  = blurPx > 0 ? `blur(${blurPx}px)` : 'none';
  q.domNode.style.zIndex  = String(Math.floor(normDepth * 500));
});

7. The Neural Mesh

The glowing "constellation" lines connect each node to its 3 nearest neighbors on the sphere. Distances are computed once at startup on the unit vectors, so they never change as the sphere rotates. A Set of "a-b" keys stops the same edge from being drawn twice (A→B and B→A). Each edge also gets a random speed and offset so that small "data packets" slide along the lines out of sync, which keeps the mesh feeling alive.

JavaScript
const edges = [];
const edgeSet = new Set();

nodes.forEach((a, idxA) => {
  const nearest = nodes
    .map((b, idxB) => {
      if (idxA === idxB) return { idx: idxB, d: Infinity };
      const dx = a.ux - b.ux, dy = a.uy - b.uy, dz = a.uz - b.uz;
      return { idx: idxB, d: dx * dx + dy * dy + dz * dz };  // squared distance is enough to sort
    })
    .sort((m, n) => m.d - n.d)
    .slice(0, 3);                                            // 3 nearest neighbors

  nearest.forEach(item => {
    a.neighbors.push(item.idx);
    const key = idxA < item.idx ? `${idxA}-${item.idx}` : `${item.idx}-${idxA}`;
    if (!edgeSet.has(key)) {                                 // A-B and B-A are the same edge
      edgeSet.add(key);
      edges.push({ a: idxA, b: item.idx, pulseOffset: Math.random(), pulseSpeed: 0.25 + Math.random() * 0.45 });
    }
  });
});

// In the render loop: a "data packet" slides along each edge
const t  = (timeSec * edge.pulseSpeed + edge.pulseOffset) % 1;
const px = qa.sx + (qb.sx - qa.sx) * t;
const py = qa.sy + (qb.sy - qa.sy) * t;

Comparing every point to every other point is fine for 30 nodes (900 checks, done once). For thousands of points, bucket them into a spatial grid first so each point only compares against its neighbors.

8. Target Lock: Spinning a Node to the Front

When you click a node, the sphere should rotate so it ends up dead center, closest to the camera, at (0, 0, -1). Working backwards through the two rotations gives the exact angles:

  • Yaw swings the point into the x = 0 plane on the camera's side: atan2(-ux, -uz).
  • Pitch then tips it onto the camera axis: -asin(uy).

Two details make it feel polished. The yaw difference is wrapped into the range ±π so the sphere always takes the short way round. And instead of jumping, each frame closes 10% of the remaining gap, which gives a natural ease-out that starts fast and settles gently.

JavaScript
function focusOnNode(idx) {
  const q = nodes[idx];

  // Yaw swings the point into the x = 0 plane on the camera's side (z < 0):
  //   needs sin(yaw) = -ux / r and cos(yaw) = -uz / r
  const desiredYaw = Math.atan2(-q.ux, -q.uz);

  // Pitch then tips it down (or up) onto the camera axis: needs sin(pitch) = -uy
  const desiredPitch = -Math.asin(Math.max(-1, Math.min(1, q.uy)));

  // Take the short way around (never spin 350 degrees to move 10)
  const twoPi = Math.PI * 2;
  const diffYaw = ((desiredYaw - rotYaw) % twoPi + twoPi * 1.5) % twoPi - Math.PI;

  targetYaw = rotYaw + diffYaw;
  targetPitch = desiredPitch;
}

// Every frame: close 10% of the remaining gap (a smooth ease-out)
if (targetYaw !== null) {
  rotYaw   += (targetYaw   - rotYaw)   * 0.1;
  rotPitch += (targetPitch - rotPitch) * 0.1;
}

Tip: the signs here are easy to get wrong, and a wrong sign doesn't throw an error; it just parks your node on the back of the sphere. Check the math numerically: rotate each point with your angles and confirm it lands at (0, 0, -1).

9. The Render Loop

Everything runs from one requestAnimationFrame loop. Motion comes first (target lock, or drag inertia plus auto-orbit), then the 30 labels are projected and moved, then the canvas is repainted back to front. The time step is clamped to 50ms so that returning to a background tab doesn't make the sphere lurch.

JavaScript
function animate(now) {
  const dt = Math.min(0.05, (now - lastFrameTime) / 1000);  // clamp after a background tab
  lastFrameTime = now;
  const timeSec = now * 0.001;

  // 1. Motion: target lock, or drag inertia + auto-orbit
  if (targetYaw !== null) { /* ease toward the locked node */ }
  else if (!isDragging) {
    velYaw *= 0.92; velPitch *= 0.92;                        // friction
    rotYaw += velYaw + (autoOrbit ? orbitSpeed * 0.45 * dt : 0);
  }

  // 2. Project all 30 nodes and move their DOM labels
  // 3. Repaint the canvas, back to front
  ctx.clearRect(0, 0, vpWidth, vpHeight);
  drawCyberpunkGrid(cx, cy, fov, timeSec);          // scrolling floor + ceiling
  drawLatentTorus(cx, cy, fov, baseRadius, timeSec); // wireframe ring
  /* core glow, neural mesh + packets, spinning target reticle */

  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Dragging works the same way: pointermove sets a velocity, and the loop multiplies it by 0.92 each frame. Let go mid-swipe and the sphere coasts to a stop like a real object. Pitch is clamped to about ±77° while dragging so you can never flip the sphere upside down.

10. Wiring It to Real Data

The demo generates its 30 nodes in JavaScript, but nothing depends on that. Because positions are computed from each row's index, you can feed it rows from a real SQL table (servers, offices, sensors) and the spiral spreads them evenly whatever the count. Here's an example of the shape that swap takes:

JavaScript
// Swap the generated demo records for real rows (e.g. from an ASP.NET endpoint
// that returns JSON). Positions are still computed client-side, so any row
// count works: the Fibonacci spiral spreads them evenly either way.
const rows = await fetch('/api/nodes').then(r => r.json());

const nodes = rows.map((row, i) => {
  const uy = 1 - (i / Math.max(1, rows.length - 1)) * 2;
  const radiusAtY = Math.sqrt(Math.max(0, 1 - uy * uy));
  const theta = (2 * Math.PI * i) / goldenRatio;
  return {
    index: i,
    code: row.code, city: row.city, status: row.status, ping: row.pingMs, load: row.loadPct,
    ux: Math.cos(theta) * radiusAtY, uy, uz: Math.sin(theta) * radiusAtY,
    neighbors: []
  };
});

When you render real data, set textContent rather than building HTML strings, so a name or note from the database can never inject markup into the page.

11. Polish Checklist

  • Cap the canvas resolution. The demo limits devicePixelRatio to 2, which keeps phones with 3x screens from drawing nine times the pixels.
  • Respect reduced motion. A sci-fi UI is mostly animation, so give visitors who disable it in their OS a calmer version:
CSS
/* Respect visitors who turn off animations in their OS settings */
@media (prefers-reduced-motion: reduce) {
  .scanbeam, .eq-bar, .hud-ring-cw, .hud-ring-ccw,
  .glitch-title::before, .glitch-title::after { animation: none; }
}
  • Keep overlays click-through. Every decorative layer (CRT, scan beam, rings) uses pointer-events: none.
  • Mind the wheel. The viewport uses the scroll wheel for zoom, which means it captures scrolling. That's great full-screen, but keep it in mind if you embed the dashboard inside a longer page.

Key Architectural Principles

  • Right tool per layer: canvas for lines and glow, DOM for text and clicks, CSS for ambient motion.
  • Fibonacci sphere: even height steps plus a golden-ratio twist spread any number of points evenly.
  • Billboards, not 3D text: project the position, keep the label flat, and fake depth with opacity, blur and z-index.
  • One loop, transform-only updates: a single requestAnimationFrame drives everything and avoids layout work.
  • Short labels on the sphere, detail in the panels: 3-letter codes stay readable at a glance.
  • Accents with meaning: aqua for data, purple for structure, lime for "this is the one you selected".

Need something built?

We design websites for free, animate logos, and build custom software.

Explore our services