3D & Experimental
GlobeMotion
The night side of the Earth with real continents. City lights glow, the sun line sits across it, and lights fly the routes you give it. Runs by itself, nothing to drag. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/globe-motion.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
export type GlobeRoute = {
/** [latitude, longitude] */
from: [number, number];
/** [latitude, longitude] */
to: [number, number];
};
type GlobeMotionProps = {
/** Lights fly along these. */
routes?: GlobeRoute[];
/** Degrees it turns each second. */
speed?: number;
/** Longitude facing you at the start. */
center?: number;
/** Degrees the north pole leans toward you. */
tilt?: number;
/** Six-digit hex colour of the land and the air around it. */
color?: string;
/** Six-digit hex colour of the sea. */
sea?: string;
/** Six-digit hex colour of the routes and the city lights. */
flight?: string;
/** Name read out by screen readers. */
label?: string;
/** Set the size here. */
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///8B4f///3X8AQD+8+//////wP///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",
"AAAAAPB/AAD/GQAA4BsAAAAAAOB/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 MAX_PIXELS = 1400000;
const FILL = 0.86;
const DEG = Math.PI / 180;
const STEPS = 48;
const TAIL = 15;
const ROUTES: GlobeRoute[] = [
{ from: [13.08, 80.27], to: [51.51, -0.13] },
{ from: [1.35, 103.82], to: [35.68, 139.69] },
{ from: [25.2, 55.27], to: [40.71, -74.01] },
{ from: [-33.87, 151.21], to: [13.08, 80.27] },
{ from: [51.51, -0.13], to: [-23.55, -46.63] },
];
const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`;
const FRAGMENT = `precision highp float;
uniform vec2 res;
uniform mat3 turn;
uniform sampler2D land;
uniform vec3 ground,water,lamp;
const vec3 sun=vec3(-.76,.3,.38);
const float PI=3.14159265;
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
void main(){
float side=min(res.x,res.y);
vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*side*${FILL});
float r=length(uv),px=2./(side*${FILL});
float aura=pow(clamp(1.-(r-1.)/.16,0.,1.),3.)*.55;
vec4 col=vec4(ground*aura,aura);
if(r<1.){
vec3 n=vec3(uv,sqrt(1.-r*r)),q=turn*n;
vec2 spot=vec2(atan(q.x,q.z)/(2.*PI)+.5,.5-asin(clamp(q.y,-1.,1.))/PI);
float solid=smoothstep(.36,.62,texture2D(land,spot).r);
float day=smoothstep(-.12,.5,dot(n,sun));
vec3 c=mix(water,ground*.52,solid)*(.2+.75*day);
vec2 g=spot*vec2(300.,150.),cell=floor(g);
float lights=step(.72,hash(cell))*(1.-smoothstep(.1,.36,length(fract(g)-.5)))*solid*(1.-day);
c+=lamp*lights*1.3;
c+=ground*pow(1.-n.z,3.)*.7*(.3+.7*day);
float cover=1.-smoothstep(1.-px*1.6,1.,r);
col=col*(1.-cover)+vec4(c*cover,cover);
}
gl_FragColor=col;
}`;
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
function program(gl: WebGLRenderingContext) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* One byte per map cell, softened with its neighbours so the coast is not a staircase. */
function landTexture() {
const bits = Uint8Array.from(atob(LAND), (c) => c.charCodeAt(0));
const isLand = (row: number, col: number) => {
const bit = Math.max(0, Math.min(59, row)) * 120 + (col + 120) % 120;
return (bits[bit >> 3] >> (bit & 7)) & 1;
};
return Uint8Array.from({ length: 7200 }, (_, i) => {
const row = Math.floor(i / 120);
const col = i % 120;
let sum = 0;
for (const y of [-1, 0, 1]) for (const x of [-1, 0, 1]) sum += (2 - Math.abs(x)) * (2 - Math.abs(y)) * isLand(row + y, col + x);
return (sum / 16) * 255;
});
}
const toPoint = ([lat, lng]: [number, number]) => [
Math.cos(lat * DEG) * Math.sin(lng * DEG),
Math.sin(lat * DEG),
Math.cos(lat * DEG) * Math.cos(lng * DEG),
];
/* Points along the short way round, lifted off the ground in the middle. */
function arc(route: GlobeRoute) {
const a = toPoint(route.from);
const b = toPoint(route.to);
const angle = Math.acos(Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2])));
if (angle < 0.01) return [];
const lift = 0.12 + angle * 0.13;
return Array.from({ length: STEPS + 1 }, (_, i) => {
const s = i / STEPS;
const p = Math.sin((1 - s) * angle) / Math.sin(angle);
const q = Math.sin(s * angle) / Math.sin(angle);
const up = 1 + lift * Math.sin(Math.PI * s);
return [0, 1, 2].map((k) => (p * a[k] + q * b[k]) * up);
});
}
/* GlobeMotion: the night side of the Earth with real continents. City lights glow, and lights fly the routes you give it. Runs only while on screen. */
export function GlobeMotion({
routes = ROUTES,
speed = 7,
center = 70,
tilt = 16,
color = "#a78bfa",
sea = "#120f2e",
flight = "#fde68a",
label = "A turning globe with flights between cities",
className = "aspect-square w-96",
}: GlobeMotionProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
const plainRef = useRef<HTMLDivElement>(null);
const paths = JSON.stringify(routes);
useEffect(() => {
const box = boxRef.current;
const plain = plainRef.current;
if (!box || !plain) return;
const canvas = document.createElement("canvas");
const lines = document.createElement("canvas");
for (const layer of [canvas, lines]) {
layer.setAttribute("aria-hidden", "true");
layer.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none";
}
const gl = canvas.getContext("webgl", { alpha: true, antialias: false });
const pen = lines.getContext("2d");
const made = gl && program(gl);
if (!gl || !pen || !made) return;
gl.useProgram(made);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const corner = gl.getAttribLocation(made, "p");
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(made, name);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
for (const wrap of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T]) gl.texParameteri(gl.TEXTURE_2D, wrap, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 120, 60, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, landTexture());
gl.uniform3fv(at("ground"), rgb(color));
gl.uniform3fv(at("water"), rgb(sea));
gl.uniform3fv(at("lamp"), rgb(flight));
const trips = (JSON.parse(paths) as GlobeRoute[]).map(arc).filter((trip) => trip.length);
const [red, green, blue] = rgb(flight).map((v) => Math.round(v * 255));
const ink = (alpha: number) => `rgba(${red},${green},${blue},${alpha})`;
const ct = Math.cos(tilt * DEG);
const st = Math.sin(tilt * DEG);
let raf = 0;
let last = 0;
let inView = false;
let time = 0;
let wide = 0;
let high = 0;
const draw = () => {
const turn = (-center + time * speed) * DEG;
const c = Math.cos(turn), s = Math.sin(turn);
const m = [c, 0, s, st * s, ct, -st * c, -ct * s, st, ct * c];
gl.uniformMatrix3fv(at("turn"), false, m);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const size = (Math.min(wide, high) / 2) * FILL;
const place = ([x, y, z]: number[]) => {
const vx = m[0] * x + m[1] * y + m[2] * z;
const vy = m[3] * x + m[4] * y + m[5] * z;
const vz = m[6] * x + m[7] * y + m[8] * z;
return { x: wide / 2 + vx * size, y: high / 2 - vy * size, seen: vz >= 0 || vx * vx + vy * vy >= 1 };
};
const stroke = (a: ReturnType<typeof place>, b: ReturnType<typeof place>) => {
if (!a.seen || !b.seen) return;
pen.beginPath();
pen.moveTo(a.x, a.y);
pen.lineTo(b.x, b.y);
pen.stroke();
};
pen.clearRect(0, 0, wide, high);
pen.lineCap = "round";
trips.forEach((trip, n) => {
const dots = trip.map(place);
pen.lineWidth = 1;
pen.strokeStyle = ink(0.22);
for (let i = 1; i <= STEPS; i++) stroke(dots[i - 1], dots[i]);
const clock = reduce ? 0.5 : (time / 5 + n / trips.length) % 1;
const head = clock * 1.3 * STEPS;
pen.lineWidth = 2;
for (let i = Math.max(1, Math.ceil(head - TAIL)); i <= Math.min(STEPS, head); i++) {
pen.strokeStyle = ink(1 - (head - i) / TAIL);
stroke(dots[i - 1], dots[i]);
}
if (head > STEPS && dots[STEPS].seen) {
const grown = (head - STEPS) / (0.3 * STEPS);
pen.lineWidth = 1.5;
pen.strokeStyle = ink(1 - grown);
pen.beginPath();
pen.arc(dots[STEPS].x, dots[STEPS].y, 2 + grown * 11, 0, Math.PI * 2);
pen.stroke();
}
for (const end of [dots[0], dots[STEPS]]) {
if (!end.seen) continue;
pen.fillStyle = ink(0.9);
pen.beginPath();
pen.arc(end.x, end.y, 2, 0, Math.PI * 2);
pen.fill();
}
});
};
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
time += dt;
draw();
raf = inView ? requestAnimationFrame(frame) : 0;
};
const start = () => {
if (inView && !raf && !reduce) raf = requestAnimationFrame(frame);
};
const fit = () => {
const { width, height } = box.getBoundingClientRect();
if (!width || !height) return;
wide = width;
high = height;
const dpr = Math.min(window.devicePixelRatio, 2);
const scale = Math.min(Math.max(dpr, 1.5), Math.sqrt(MAX_PIXELS / (width * height)));
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(at("res"), canvas.width, canvas.height);
lines.width = Math.round(width * dpr);
lines.height = Math.round(height * dpr);
pen.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
};
fit();
box.append(canvas, lines);
plain.hidden = true;
const sized = new ResizeObserver(fit);
sized.observe(box);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
start();
});
watch.observe(box);
const lost = () => {
canvas.remove();
lines.remove();
plain.hidden = false;
};
canvas.addEventListener("webglcontextlost", lost);
return () => {
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
canvas.removeEventListener("webglcontextlost", lost);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
lines.remove();
plain.hidden = false;
};
}, [paths, speed, center, tilt, color, sea, flight, reduce]);
return (
<div ref={boxRef} role="img" aria-label={label} className={`relative overflow-hidden ${className}`}>
<div
ref={plainRef}
className="absolute left-[7%] top-[7%] size-[86%] rounded-full"
style={{ background: `radial-gradient(circle at 35% 30%, ${color}, ${sea} 70%)` }}
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/globe-motion.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/globe-motion-demo.tsx
import { GlobeMotion } from "@/components/ui/globe-motion"; export default function GlobeMotionExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-2"> <GlobeMotion className="aspect-square h-full max-h-[400px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| routes | GlobeRoute[] | 5 sample routes | Each has from and to, both [latitude, longitude]. |
| speed | number | 7 | Degrees it turns each second. |
| center | number | 70 | Longitude facing you at the start. |
| tilt | number | 16 | Degrees the north pole leans toward you. |
| color | string | "#a78bfa" | Six-digit hex colour of the land and the air around it. |
| sea | string | "#120f2e" | Six-digit hex colour of the sea. |
| flight | string | "#fde68a" | Six-digit hex colour of the routes and the city lights. |
| label | string | "A turning globe with flights between cities" | Name read out by screen readers. |
| className | string | "aspect-square w-96" | Set the size here. |