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.
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 Monofor data (every digit lines up) andOrbitronfor headings. - Machine-style labels.
TARGET_LOCK // SQL,YAW_RAD,SYNC_OK. Upper snake case with//separators instantly says "system readout".
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:
| Layer | Tech | What it draws |
|---|---|---|
| z-0 | Canvas 2D | Perspective grid, torus wireframe, core glow, neighbor mesh, data packets, target reticle |
| z-10 | SVG + CSS | Slow counter-rotating radar rings (pure CSS animation, zero JS) |
| z-20 | DOM | 30 node labels: crisp text, hover, click |
| z-30 | DOM | Glass HUD panels, control deck, node list |
| z-999 | CSS | CRT 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.
<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.
: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.
<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:
- Step the height
yevenly 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. - 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.
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.
// 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.
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.
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 = 0plane 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.
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.
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:
// 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
devicePixelRatioto 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:
/* 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
requestAnimationFramedrives 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".