Interactive
InteractiveGlobe
A dotted Earth with real continents, drawn on a canvas with no 3D library. It turns by itself, drags with inertia, and arrow keys turn it too. Pins pulse and carry their names.
Download
- React
- Tailwind CSS v4
- motion
components/ui/interactive-globe.tsx
"use client";
import { useEffect, useRef } from "react";
import { useInView, useReducedMotion } from "motion/react";
export type GlobeMarker = { lat: number; lng: number; label: string };
type InteractiveGlobeProps = {
/** Pins on the map. Each stays named for screen readers. */
markers?: GlobeMarker[];
/** Degrees per second when left alone. 0 stands still. */
speed?: number;
/** Longitude facing you at the start. */
start?: number;
/** Latitude facing you at the start. */
tilt?: number;
color?: string;
markerColor?: string;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
/* 120 x 60 land map, one bit per 3 degree cell, from Natural Earth (public domain). */
const LAND = [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgH/wHwAAAAAAAAAAAAAA8Mf/PwALAAAYAAAAAACY9OP/HwAAAATAAQAA",
"AAAYfQP+HwAAQID/LwgAwAPA0x/8BwAwAOz///8BHgAAAIoD/v8BDBAAAAAAwP///z04YAD3//////9/4P//HwwwAMD7//////86",
"AMH/H1wAAETz/////wcCAAD/f/wAAAT5/////wEDAAD+//0BAPr//////w8BAAD4/z8AAPj//////w8AAAD4/z8AAPi/3v///wsA",
"AAD4/x8AALof7v///wkAAAD4/wcAAA7O3///bwAAAAD4/wcAAA7p3///XwQAAADw/wMAAHwA////DwMAAADg/wEAAP4Z////nwAA",
"AACADwEAAP9/7///HwAAAAAADwAAgP//H///DwAAAAAABgIAgP///vj7AwAAAAAAThAAgP//fPB4AQAAAAAAeAAAwP//PXD4EAAA",
"AAAAwAAAwP//D2DgAAAAAAAAgLgAgP//H2CAAAAAAAAAAPwAAP//HwAAIAAAAAAAAPwHAML/DwAQDAAAAAAAAPwHAID/BwBgBwAA",
"AAAAAP4fAID/AwBAFgEAAAAAAP5/AAD/AQBAAA4AAAAAAP7/AAD/AQAAAxwAAAAAAPz/AAD/AQAAAAAAAAAAAPh/AAD/EwAAgAkA",
"/////w+A//8A5v//H+T/AAAAAOB/AAD/CAAA8B8AAAAAAOA/AAD+DAAA/D8AAAAAAPAPAAB+AAAA/H8AAAAAAPAPAAB+AAAA/H8A",
"AAAAAPAHAAA8AAAA+H8AAAAAAPADAAAEAAAACDwAAAAAAPgBAAAAAAAAADhAAAAAAHgAAAAAAAAAAABAAAAAADAAAAAAAAAAAAAg",
"AAAAADgAAAAAAAAAAAAQAAAAABgAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAACAAAAAA8ID//x8AAAAAAHAAAED3//f///8DAAAI9PIAgP////////8P",
"APj//x8A+P////////8HQPz//wM4/P////////8DAP7////8//////////8HAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
].join("");
const DOTS = 7000;
const DEG = Math.PI / 180;
const MAX_TILT = 70 * DEG;
const STEP = 8 * DEG;
let landDots: Float32Array | null = null;
/* Even dots over the sphere, kept only where the map says land. */
function getLandDots() {
if (landDots) return landDots;
const bits = Uint8Array.from(atob(LAND), (c) => c.charCodeAt(0));
const found: number[] = [];
for (let i = 0; i < DOTS; i++) {
const y = 1 - (2 * (i + 0.5)) / DOTS;
const around = Math.sqrt(1 - y * y);
const turn = i * Math.PI * (3 - Math.sqrt(5));
const x = Math.sin(turn) * around;
const z = Math.cos(turn) * around;
const row = Math.min(59, Math.floor(((90 - Math.asin(y) / DEG) / 180) * 60));
const col = Math.min(119, Math.floor(((Math.atan2(x, z) / DEG + 180) / 360) * 120));
const bit = row * 120 + col;
if ((bits[bit >> 3] >> (bit & 7)) & 1) found.push(x, y, z);
}
landDots = new Float32Array(found);
return landDots;
}
/* InteractiveGlobe: a dotted Earth with real continents on a canvas. It turns by itself, drags with inertia, and arrow keys turn it too. */
export function InteractiveGlobe({
markers = [],
speed = 6,
start = 78,
tilt = 15,
color = "#a78bfa",
markerColor = "#f0abfc",
label = "Globe",
className = "aspect-square w-full",
}: InteractiveGlobeProps) {
const reduce = useReducedMotion();
const canvas = useRef<HTMLCanvasElement>(null);
const seen = useInView(canvas);
const view = useRef({ yaw: -start * DEG, tilt: tilt * DEG, spin: 0, radius: 100, held: false, dirty: true, lastX: 0, lastY: 0 });
useEffect(() => {
const el = canvas.current;
const ctx = el?.getContext("2d");
if (!el || !ctx || !seen) return;
const dots = getLandDots();
const v = view.current;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let width = 0;
let height = 0;
const fit = () => {
width = el.clientWidth;
height = el.clientHeight;
el.width = width * dpr;
el.height = height * dpr;
v.dirty = true;
};
fit();
const observer = new ResizeObserver(fit);
observer.observe(el);
const draw = (now: number) => {
const radius = Math.min(width, height) / 2 - 10;
if (radius <= 0) return;
v.radius = radius;
const cx = width / 2;
const cy = height / 2;
const sinY = Math.sin(v.yaw);
const cosY = Math.cos(v.yaw);
const sinT = Math.sin(v.tilt);
const cosT = Math.cos(v.tilt);
const place = (x: number, y: number, z: number) => {
const z1 = z * cosY - x * sinY;
return [cx + (x * cosY + z * sinY) * radius, cy - (y * cosT - z1 * sinT) * radius, y * sinT + z1 * cosT];
};
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.globalAlpha = 0.06;
ctx.fill();
ctx.globalAlpha = 0.3;
ctx.stroke();
const size = Math.max(1.2, radius / 90);
for (let i = 0; i < dots.length; i += 3) {
const [px, py, pz] = place(dots[i], dots[i + 1], dots[i + 2]);
if (pz <= 0) continue;
ctx.globalAlpha = 0.25 + 0.75 * pz;
ctx.fillRect(px - size / 2, py - size / 2, size, size);
}
ctx.font = "500 12px system-ui, sans-serif";
ctx.textBaseline = "middle";
ctx.strokeStyle = markerColor;
const pulse = reduce ? 0 : (now % 1800) / 1800;
for (const marker of markers) {
const lat = marker.lat * DEG;
const lng = marker.lng * DEG;
const [px, py, pz] = place(Math.cos(lat) * Math.sin(lng), Math.sin(lat), Math.cos(lat) * Math.cos(lng));
if (pz <= 0.1) continue;
const fade = Math.min(1, pz * 3);
ctx.globalAlpha = fade * (1 - pulse);
ctx.beginPath();
ctx.arc(px, py, 3.5 + pulse * 12, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = fade;
ctx.fillStyle = markerColor;
ctx.beginPath();
ctx.arc(px, py, 3.5, 0, Math.PI * 2);
ctx.fill();
const wide = ctx.measureText(marker.label).width;
ctx.fillStyle = "#ffffff";
ctx.fillText(marker.label, px + 10 + wide > width ? px - 10 - wide : px + 10, py);
}
ctx.globalAlpha = 1;
v.dirty = false;
};
const moving = !reduce && (speed !== 0 || markers.length > 0);
let frame = 0;
let last = performance.now();
const loop = (now: number) => {
frame = requestAnimationFrame(loop);
const delta = Math.min(0.05, (now - last) / 1000);
last = now;
if (!v.held) {
v.spin *= Math.exp(-delta * 3);
if (Math.abs(v.spin) < 0.002) v.spin = 0;
v.yaw += ((reduce ? 0 : speed * DEG) + v.spin) * delta;
}
if (moving || v.spin !== 0 || v.dirty) draw(now);
};
frame = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [seen, reduce, markers, speed, color, markerColor]);
const onDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
const v = view.current;
v.held = true;
v.spin = 0;
v.lastX = e.clientX;
v.lastY = e.clientY;
e.currentTarget.setPointerCapture(e.pointerId);
};
const onMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
const v = view.current;
if (!v.held) return;
const turn = (e.clientX - v.lastX) / v.radius;
v.yaw += turn;
v.spin = reduce ? 0 : turn * 60;
v.tilt = Math.max(-MAX_TILT, Math.min(MAX_TILT, v.tilt + (e.clientY - v.lastY) / v.radius));
v.lastX = e.clientX;
v.lastY = e.clientY;
v.dirty = true;
};
const onUp = () => {
view.current.held = false;
};
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const v = view.current;
if (e.key === "ArrowLeft") v.yaw -= STEP;
else if (e.key === "ArrowRight") v.yaw += STEP;
else if (e.key === "ArrowUp") v.tilt = Math.max(-MAX_TILT, v.tilt - STEP);
else if (e.key === "ArrowDown") v.tilt = Math.min(MAX_TILT, v.tilt + STEP);
else return;
e.preventDefault();
v.dirty = true;
};
return (
<div
role="group"
tabIndex={0}
aria-label={`${label}. Arrow keys turn it.`}
onKeyDown={onKey}
className={`relative rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${className}`}
>
<canvas
ref={canvas}
aria-hidden
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className="block h-full w-full cursor-grab touch-pan-y active:cursor-grabbing"
/>
{markers.length > 0 && (
<ul className="sr-only">
{markers.map((marker) => (
<li key={marker.label}>{marker.label}</li>
))}
</ul>
)}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/interactive-globe.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/interactive-globe-demo.tsx
import { InteractiveGlobe } from "@/components/ui/interactive-globe"; const OFFICES = [ { lat: 13.08, lng: 80.27, label: "Chennai" }, { lat: 1.35, lng: 103.82, label: "Singapore" }, { lat: 25.2, lng: 55.27, label: "Dubai" }, { lat: 51.51, lng: -0.13, label: "London" }, { lat: 40.71, lng: -74.01, label: "New York" }, ]; export default function InteractiveGlobeExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-3"> <InteractiveGlobe markers={OFFICES} label="Our offices" className="aspect-square h-full max-h-[360px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| markers | GlobeMarker[] | [] | Pins: lat, lng and label. Each stays named for screen readers. |
| speed | number | 6 | Degrees per second when left alone. 0 stands still. |
| start | number | 78 | Longitude facing you at the start. |
| tilt | number | 15 | Latitude facing you at the start. |
| color | string | "#a78bfa" | Land dots and outline. |
| markerColor | string | "#f0abfc" | Pins and their pulse. |
| label | string | "Globe" | Read by screen readers. |
| className | string | "aspect-square w-full" | Replaces the size. The globe fits the smaller side. |