UI Components Hard
Interactive 3D Globe
Interactive 3D globe rendered on canvas with dot-matrix sphere, auto-rotation, and drag-to-rotate interaction — no Three.js required.
Open in Lab
MCP
css javascript canvas vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
background: #0a0a0a;
overflow: hidden;
}
.globe-wrapper {
position: relative;
width: 100%;
height: 100vh;
display: grid;
place-items: center;
background: #0a0a0a;
overflow: hidden;
}
#globe-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: grab;
}
#globe-canvas:active {
cursor: grabbing;
}
.globe-content {
position: relative;
z-index: 10;
text-align: center;
pointer-events: none;
transform: translateY(-60%);
}
.globe-title {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #a5f3fc 0%, #06b6d4 50%, #0891b2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.globe-subtitle {
font-size: clamp(0.875rem, 2vw, 1.125rem);
color: rgba(148, 163, 184, 0.8);
font-weight: 400;
}// Interactive 3D Globe — canvas dot-sphere with drag rotation
(function () {
"use strict";
const canvas = document.getElementById("globe-canvas");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const DOT_COUNT = 800;
const DOT_RADIUS = 1.8;
const AUTO_ROTATE_SPEED = 0.003;
const PERSPECTIVE = 600;
const DOT_COLOR = { r: 6, g: 182, b: 212 }; // cyan-500
const GLOW_COLOR = "rgba(6, 182, 212, 0.15)";
let width, height, radius;
let rotY = 0;
let rotX = 0.3;
let isDragging = false;
let lastMouse = { x: 0, y: 0 };
let autoRotate = true;
let dragTimeout;
// Generate Fibonacci sphere points
const points = [];
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < DOT_COUNT; i++) {
const y = 1 - (i / (DOT_COUNT - 1)) * 2; // y goes from 1 to -1
const radiusAtY = Math.sqrt(1 - y * y);
const theta = goldenAngle * i;
points.push({
x: Math.cos(theta) * radiusAtY,
y: y,
z: Math.sin(theta) * radiusAtY,
});
}
function resize() {
const dpr = window.devicePixelRatio || 1;
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + "px";
canvas.style.height = height + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
radius = Math.min(width, height) * 0.3;
}
resize();
window.addEventListener("resize", resize);
function rotatePoint(p, ry, rx) {
// Rotate around Y axis
let x = p.x * Math.cos(ry) - p.z * Math.sin(ry);
let z = p.x * Math.sin(ry) + p.z * Math.cos(ry);
let y = p.y;
// Rotate around X axis
const y2 = y * Math.cos(rx) - z * Math.sin(rx);
const z2 = y * Math.sin(rx) + z * Math.cos(rx);
return { x, y: y2, z: z2 };
}
function project(p) {
const scale = PERSPECTIVE / (PERSPECTIVE + p.z * radius);
return {
x: p.x * radius * scale + width / 2,
y: p.y * radius * scale + height / 2 + 60,
scale,
z: p.z,
};
}
function draw() {
ctx.clearRect(0, 0, width, height);
// Draw glow behind globe
const gradient = ctx.createRadialGradient(
width / 2,
height / 2 + 60,
radius * 0.2,
width / 2,
height / 2 + 60,
radius * 1.2
);
gradient.addColorStop(0, GLOW_COLOR);
gradient.addColorStop(1, "transparent");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Sort by z for proper depth rendering
const projected = points.map((p) => {
const rotated = rotatePoint(p, rotY, rotX);
return project(rotated);
});
projected.sort((a, b) => a.z - b.z);
for (const p of projected) {
// Dots facing camera are brighter
const alpha = Math.max(0.08, (p.z + 1) / 2);
const dotSize = DOT_RADIUS * p.scale;
ctx.beginPath();
ctx.arc(p.x, p.y, dotSize, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${DOT_COLOR.r}, ${DOT_COLOR.g}, ${DOT_COLOR.b}, ${alpha})`;
ctx.fill();
// Highlight glow on front-facing dots
if (p.z > 0.3) {
ctx.beginPath();
ctx.arc(p.x, p.y, dotSize * 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${DOT_COLOR.r}, ${DOT_COLOR.g}, ${DOT_COLOR.b}, ${alpha * 0.15})`;
ctx.fill();
}
}
}
function tick() {
if (autoRotate && !isDragging) {
rotY += AUTO_ROTATE_SPEED;
}
draw();
requestAnimationFrame(tick);
}
// Mouse interaction
canvas.addEventListener("mousedown", (e) => {
isDragging = true;
lastMouse = { x: e.clientX, y: e.clientY };
clearTimeout(dragTimeout);
autoRotate = false;
});
window.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const dx = e.clientX - lastMouse.x;
const dy = e.clientY - lastMouse.y;
rotY += dx * 0.005;
rotX += dy * 0.005;
rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotX));
lastMouse = { x: e.clientX, y: e.clientY };
});
window.addEventListener("mouseup", () => {
isDragging = false;
dragTimeout = setTimeout(() => {
autoRotate = true;
}, 2000);
});
// Touch interaction
canvas.addEventListener(
"touchstart",
(e) => {
isDragging = true;
const t = e.touches[0];
lastMouse = { x: t.clientX, y: t.clientY };
clearTimeout(dragTimeout);
autoRotate = false;
},
{ passive: true }
);
canvas.addEventListener(
"touchmove",
(e) => {
if (!isDragging) return;
const t = e.touches[0];
const dx = t.clientX - lastMouse.x;
const dy = t.clientY - lastMouse.y;
rotY += dx * 0.005;
rotX += dy * 0.005;
rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotX));
lastMouse = { x: t.clientX, y: t.clientY };
},
{ passive: true }
);
canvas.addEventListener("touchend", () => {
isDragging = false;
dragTimeout = setTimeout(() => {
autoRotate = true;
}, 2000);
});
tick();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interactive 3D Globe</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="globe-wrapper">
<canvas id="globe-canvas"></canvas>
<div class="globe-content">
<h1 class="globe-title">3D Globe</h1>
<p class="globe-subtitle">Drag to explore — pure canvas, no libraries</p>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useRef, useEffect, useCallback, useState } from "react";
interface Globe3DProps {
dotCount?: number;
dotRadius?: number;
autoRotateSpeed?: number;
color?: { r: number; g: number; b: number };
className?: string;
}
interface Point3D {
x: number;
y: number;
z: number;
}
const PERSPECTIVE = 600;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
function generateFibonacciSphere(count: number): Point3D[] {
const pts: Point3D[] = [];
for (let i = 0; i < count; i++) {
const y = 1 - (i / (count - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const theta = GOLDEN_ANGLE * i;
pts.push({ x: Math.cos(theta) * r, y, z: Math.sin(theta) * r });
}
return pts;
}
function rotatePoint(p: Point3D, ry: number, rx: number): Point3D {
const x = p.x * Math.cos(ry) - p.z * Math.sin(ry);
const z = p.x * Math.sin(ry) + p.z * Math.cos(ry);
const y2 = p.y * Math.cos(rx) - z * Math.sin(rx);
const z2 = p.y * Math.sin(rx) + z * Math.cos(rx);
return { x, y: y2, z: z2 };
}
export function Globe3D({
dotCount = 800,
dotRadius = 1.8,
autoRotateSpeed = 0.003,
color = { r: 6, g: 182, b: 212 },
className = "",
}: Globe3DProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rotRef = useRef({ y: 0, x: 0.3 });
const dragRef = useRef({ active: false, lastX: 0, lastY: 0, autoRotate: true });
const pointsRef = useRef<Point3D[]>(generateFibonacciSphere(dotCount));
useEffect(() => {
pointsRef.current = generateFibonacciSphere(dotCount);
}, [dotCount]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
let dragTimeout: ReturnType<typeof setTimeout>;
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = canvas!.parentElement?.clientWidth || window.innerWidth;
const h = canvas!.parentElement?.clientHeight || window.innerHeight;
canvas!.width = w * dpr;
canvas!.height = h * dpr;
canvas!.style.width = w + "px";
canvas!.style.height = h + "px";
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
window.addEventListener("resize", resize);
function draw() {
const w = canvas!.clientWidth;
const h = canvas!.clientHeight;
const radius = Math.min(w, h) * 0.3;
ctx!.clearRect(0, 0, w, h);
// Glow
const grad = ctx!.createRadialGradient(
w / 2,
h / 2,
radius * 0.2,
w / 2,
h / 2,
radius * 1.2
);
grad.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, 0.15)`);
grad.addColorStop(1, "transparent");
ctx!.fillStyle = grad;
ctx!.fillRect(0, 0, w, h);
const rot = rotRef.current;
if (dragRef.current.autoRotate && !dragRef.current.active) {
rot.y += autoRotateSpeed;
}
const projected = pointsRef.current.map((p) => {
const r = rotatePoint(p, rot.y, rot.x);
const scale = PERSPECTIVE / (PERSPECTIVE + r.z * radius);
return { x: r.x * radius * scale + w / 2, y: r.y * radius * scale + h / 2, scale, z: r.z };
});
projected.sort((a, b) => a.z - b.z);
for (const p of projected) {
const alpha = Math.max(0.08, (p.z + 1) / 2);
const ds = dotRadius * p.scale;
ctx!.beginPath();
ctx!.arc(p.x, p.y, ds, 0, Math.PI * 2);
ctx!.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`;
ctx!.fill();
if (p.z > 0.3) {
ctx!.beginPath();
ctx!.arc(p.x, p.y, ds * 2.5, 0, Math.PI * 2);
ctx!.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha * 0.15})`;
ctx!.fill();
}
}
animId = requestAnimationFrame(draw);
}
const onDown = (x: number, y: number) => {
dragRef.current = { ...dragRef.current, active: true, lastX: x, lastY: y, autoRotate: false };
clearTimeout(dragTimeout);
};
const onMove = (x: number, y: number) => {
if (!dragRef.current.active) return;
const dx = x - dragRef.current.lastX;
const dy = y - dragRef.current.lastY;
rotRef.current.y += dx * 0.005;
rotRef.current.x = Math.max(
-Math.PI / 2,
Math.min(Math.PI / 2, rotRef.current.x + dy * 0.005)
);
dragRef.current.lastX = x;
dragRef.current.lastY = y;
};
const onUp = () => {
dragRef.current.active = false;
dragTimeout = setTimeout(() => {
dragRef.current.autoRotate = true;
}, 2000);
};
const md = (e: MouseEvent) => onDown(e.clientX, e.clientY);
const mm = (e: MouseEvent) => onMove(e.clientX, e.clientY);
const ts = (e: TouchEvent) => onDown(e.touches[0].clientX, e.touches[0].clientY);
const tm = (e: TouchEvent) => onMove(e.touches[0].clientX, e.touches[0].clientY);
canvas.addEventListener("mousedown", md);
window.addEventListener("mousemove", mm);
window.addEventListener("mouseup", onUp);
canvas.addEventListener("touchstart", ts, { passive: true });
canvas.addEventListener("touchmove", tm, { passive: true });
canvas.addEventListener("touchend", onUp);
draw();
return () => {
cancelAnimationFrame(animId);
clearTimeout(dragTimeout);
window.removeEventListener("resize", resize);
canvas.removeEventListener("mousedown", md);
window.removeEventListener("mousemove", mm);
window.removeEventListener("mouseup", onUp);
canvas.removeEventListener("touchstart", ts);
canvas.removeEventListener("touchmove", tm);
canvas.removeEventListener("touchend", onUp);
};
}, [dotCount, dotRadius, autoRotateSpeed, color]);
return (
<canvas
ref={canvasRef}
className={className}
style={{ width: "100%", height: "100%", cursor: "grab" }}
/>
);
}
// Demo usage
export default function Globe3DDemo() {
return (
<div
style={{
width: "100vw",
height: "100vh",
background: "#0a0a0a",
position: "relative",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
<Globe3D dotCount={800} color={{ r: 6, g: 182, b: 212 }} />
<div
style={{
position: "absolute",
top: "12%",
left: 0,
right: 0,
textAlign: "center",
pointerEvents: "none",
zIndex: 10,
}}
>
<h1
style={{
fontSize: "clamp(2rem, 5vw, 3.5rem)",
fontWeight: 800,
letterSpacing: "-0.03em",
background: "linear-gradient(135deg, #a5f3fc 0%, #06b6d4 50%, #0891b2 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
marginBottom: "0.5rem",
}}
>
3D Globe
</h1>
<p
style={{
fontSize: "clamp(0.875rem, 2vw, 1.125rem)",
color: "rgba(148, 163, 184, 0.8)",
}}
>
Drag to explore — pure canvas, no libraries
</p>
</div>
</div>
);
}<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const canvasRef = ref(null);
const PERSPECTIVE = 600;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const DOT_COUNT = 800;
const DOT_RADIUS = 1.8;
const AUTO_SPEED = 0.003;
const COLOR = { r: 6, g: 182, b: 212 };
function generateFibonacciSphere(count) {
const pts = [];
for (let i = 0; i < count; i++) {
const y = 1 - (i / (count - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const theta = GOLDEN_ANGLE * i;
pts.push({ x: Math.cos(theta) * r, y, z: Math.sin(theta) * r });
}
return pts;
}
function rotatePoint(p, ry, rx) {
const x = p.x * Math.cos(ry) - p.z * Math.sin(ry);
const z = p.x * Math.sin(ry) + p.z * Math.cos(ry);
const y2 = p.y * Math.cos(rx) - z * Math.sin(rx);
const z2 = p.y * Math.sin(rx) + z * Math.cos(rx);
return { x, y: y2, z: z2 };
}
let animId = null;
let dragTimeout = null;
const points = generateFibonacciSphere(DOT_COUNT);
const rot = { y: 0, x: 0.3 };
const drag = { active: false, lastX: 0, lastY: 0, autoRotate: true };
function onDown(x, y) {
drag.active = true;
drag.lastX = x;
drag.lastY = y;
drag.autoRotate = false;
clearTimeout(dragTimeout);
}
function onMove(x, y) {
if (!drag.active) return;
const dx = x - drag.lastX;
const dy = y - drag.lastY;
rot.y += dx * 0.005;
rot.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rot.x + dy * 0.005));
drag.lastX = x;
drag.lastY = y;
}
function onUp() {
drag.active = false;
dragTimeout = setTimeout(() => {
drag.autoRotate = true;
}, 2000);
}
function handleMouseDown(e) {
onDown(e.clientX, e.clientY);
}
function handleMouseMove(e) {
onMove(e.clientX, e.clientY);
}
function handleTouchStart(e) {
onDown(e.touches[0].clientX, e.touches[0].clientY);
}
function handleTouchMove(e) {
onMove(e.touches[0].clientX, e.touches[0].clientY);
}
onMounted(() => {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
function resize() {
const dpr = window.devicePixelRatio || 1;
const parent = canvas.parentElement;
const w = parent ? parent.clientWidth : window.innerWidth;
const h = parent ? parent.clientHeight : window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + "px";
canvas.style.height = h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
window.addEventListener("resize", resize);
function draw() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const radius = Math.min(w, h) * 0.3;
ctx.clearRect(0, 0, w, h);
const grad = ctx.createRadialGradient(w / 2, h / 2, radius * 0.2, w / 2, h / 2, radius * 1.2);
grad.addColorStop(0, `rgba(${COLOR.r}, ${COLOR.g}, ${COLOR.b}, 0.15)`);
grad.addColorStop(1, "transparent");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
if (drag.autoRotate && !drag.active) {
rot.y += AUTO_SPEED;
}
const projected = points.map((p) => {
const r = rotatePoint(p, rot.y, rot.x);
const scale = PERSPECTIVE / (PERSPECTIVE + r.z * radius);
return { x: r.x * radius * scale + w / 2, y: r.y * radius * scale + h / 2, scale, z: r.z };
});
projected.sort((a, b) => a.z - b.z);
for (const p of projected) {
const alpha = Math.max(0.08, (p.z + 1) / 2);
const ds = DOT_RADIUS * p.scale;
ctx.beginPath();
ctx.arc(p.x, p.y, ds, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${COLOR.r}, ${COLOR.g}, ${COLOR.b}, ${alpha})`;
ctx.fill();
if (p.z > 0.3) {
ctx.beginPath();
ctx.arc(p.x, p.y, ds * 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${COLOR.r}, ${COLOR.g}, ${COLOR.b}, ${alpha * 0.15})`;
ctx.fill();
}
}
animId = requestAnimationFrame(draw);
}
canvas.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", onUp);
canvas.addEventListener("touchstart", handleTouchStart, { passive: true });
canvas.addEventListener("touchmove", handleTouchMove, { passive: true });
canvas.addEventListener("touchend", onUp);
draw();
});
onUnmounted(() => {
cancelAnimationFrame(animId);
clearTimeout(dragTimeout);
window.removeEventListener("resize", () => {});
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", onUp);
});
</script>
<template>
<div style="width:100vw;height:100vh;background:#0a0a0a;position:relative;font-family:system-ui,-apple-system,sans-serif">
<canvas ref="canvasRef" style="width:100%;height:100%;cursor:grab;display:block"></canvas>
<div style="position:absolute;top:12%;left:0;right:0;text-align:center;pointer-events:none;z-index:10">
<h1 style="font-size:clamp(2rem,5vw,3.5rem);font-weight:800;letter-spacing:-0.03em;background:linear-gradient(135deg,#a5f3fc 0%,#06b6d4 50%,#0891b2 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin:0 0 0.5rem 0">
3D Globe
</h1>
<p style="font-size:clamp(0.875rem,2vw,1.125rem);color:rgba(148,163,184,0.8);margin:0">
Drag to explore — pure canvas, no libraries
</p>
</div>
</div>
</template><script>
import { onMount, onDestroy } from "svelte";
const PERSPECTIVE = 600;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const DOT_COUNT = 800;
const DOT_RADIUS = 1.8;
const AUTO_SPEED = 0.003;
const COLOR = { r: 6, g: 182, b: 212 };
let canvas;
let animId = null;
let dragTimeout = null;
let rot = { y: 0, x: 0.3 };
let drag = { active: false, lastX: 0, lastY: 0, autoRotate: true };
function generateFibonacciSphere(count) {
const pts = [];
for (let i = 0; i < count; i++) {
const y = 1 - (i / (count - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const theta = GOLDEN_ANGLE * i;
pts.push({ x: Math.cos(theta) * r, y, z: Math.sin(theta) * r });
}
return pts;
}
function rotatePoint(p, ry, rx) {
const x = p.x * Math.cos(ry) - p.z * Math.sin(ry);
const z = p.x * Math.sin(ry) + p.z * Math.cos(ry);
const y2 = p.y * Math.cos(rx) - z * Math.sin(rx);
const z2 = p.y * Math.sin(rx) + z * Math.cos(rx);
return { x, y: y2, z: z2 };
}
const points = generateFibonacciSphere(DOT_COUNT);
function onDown(x, y) {
drag.active = true;
drag.lastX = x;
drag.lastY = y;
drag.autoRotate = false;
clearTimeout(dragTimeout);
}
function onMove(x, y) {
if (!drag.active) return;
const dx = x - drag.lastX;
const dy = y - drag.lastY;
rot.y += dx * 0.005;
rot.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rot.x + dy * 0.005));
drag.lastX = x;
drag.lastY = y;
}
function onUp() {
drag.active = false;
dragTimeout = setTimeout(() => {
drag.autoRotate = true;
}, 2000);
}
function handleMouseDown(e) {
onDown(e.clientX, e.clientY);
}
function handleMouseMove(e) {
onMove(e.clientX, e.clientY);
}
function handleTouchStart(e) {
onDown(e.touches[0].clientX, e.touches[0].clientY);
}
function handleTouchMove(e) {
onMove(e.touches[0].clientX, e.touches[0].clientY);
}
let resizeHandler;
onMount(() => {
const ctx = canvas.getContext("2d");
if (!ctx) return;
function resize() {
const dpr = window.devicePixelRatio || 1;
const parent = canvas.parentElement;
const w = parent ? parent.clientWidth : window.innerWidth;
const h = parent ? parent.clientHeight : window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + "px";
canvas.style.height = h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
resizeHandler = resize;
window.addEventListener("resize", resize);
function draw() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const radius = Math.min(w, h) * 0.3;
ctx.clearRect(0, 0, w, h);
const grad = ctx.createRadialGradient(w / 2, h / 2, radius * 0.2, w / 2, h / 2, radius * 1.2);
grad.addColorStop(0, `rgba(${COLOR.r}, ${COLOR.g}, ${COLOR.b}, 0.15)`);
grad.addColorStop(1, "transparent");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
if (drag.autoRotate && !drag.active) {
rot.y += AUTO_SPEED;
}
const projected = points.map((p) => {
const r = rotatePoint(p, rot.y, rot.x);
const scale = PERSPECTIVE / (PERSPECTIVE + r.z * radius);
return { x: r.x * radius * scale + w / 2, y: r.y * radius * scale + h / 2, scale, z: r.z };
});
projected.sort((a, b) => a.z - b.z);
for (const p of projected) {
const alpha = Math.max(0.08, (p.z + 1) / 2);
const ds = DOT_RADIUS * p.scale;
ctx.beginPath();
ctx.arc(p.x, p.y, ds, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${COLOR.r}, ${COLOR.g}, ${COLOR.b}, ${alpha})`;
ctx.fill();
if (p.z > 0.3) {
ctx.beginPath();
ctx.arc(p.x, p.y, ds * 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${COLOR.r}, ${COLOR.g}, ${COLOR.b}, ${alpha * 0.15})`;
ctx.fill();
}
}
animId = requestAnimationFrame(draw);
}
canvas.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", onUp);
canvas.addEventListener("touchstart", handleTouchStart, { passive: true });
canvas.addEventListener("touchmove", handleTouchMove, { passive: true });
canvas.addEventListener("touchend", onUp);
draw();
});
onDestroy(() => {
cancelAnimationFrame(animId);
clearTimeout(dragTimeout);
if (resizeHandler) window.removeEventListener("resize", resizeHandler);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", onUp);
});
</script>
<div style="width:100vw;height:100vh;background:#0a0a0a;position:relative;font-family:system-ui,-apple-system,sans-serif">
<canvas bind:this={canvas} style="width:100%;height:100%;cursor:grab;display:block"></canvas>
<div style="position:absolute;top:12%;left:0;right:0;text-align:center;pointer-events:none;z-index:10">
<h1 style="font-size:clamp(2rem,5vw,3.5rem);font-weight:800;letter-spacing:-0.03em;background:linear-gradient(135deg,#a5f3fc 0%,#06b6d4 50%,#0891b2 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin:0 0 0.5rem 0">
3D Globe
</h1>
<p style="font-size:clamp(0.875rem,2vw,1.125rem);color:rgba(148,163,184,0.8);margin:0">
Drag to explore — pure canvas, no libraries
</p>
</div>
</div>Interactive 3D Globe
A stunning canvas-rendered 3D globe made entirely with math — no WebGL or Three.js. Dots are distributed on a sphere surface using the Fibonacci spiral, then projected to 2D with perspective. The globe auto-rotates and can be dragged to rotate manually.
How it works
- Points are placed on a sphere using the golden-angle Fibonacci spiral for even distribution
- Each frame, points are rotated around the Y and X axes using rotation matrices
- A perspective projection maps 3D coordinates to 2D canvas positions
- Dots facing away from the camera are dimmed for a realistic depth effect
- Mouse/touch drag updates rotation angles for interactive exploration
Customization
DOT_COUNTcontrols point density on the sphereRADIUSsets the globe sizeAUTO_ROTATE_SPEEDadjusts idle rotation speed- Dot color and glow can be customized via constants
When to use it
- Hero sections for global / international products
- Location or network visualizations
- Tech-forward landing pages
- Dashboard backgrounds