Globe
A dotted globe in real CSS 3D with invented land, arcs that draw between places and pulsing pins.
One of 3 components in Data animations
Details
What Globe brings along, read from the registry and the source.
- Category
- Data animations
- Area
- Visuals
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/globe.tsx
- Brings along
- utilskitmotion-scene
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The GlobeProps interface from components/ui/globe.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.
arcsoptionalGlobeArc[]Dotted arcs between two places that light up one after another
pinsoptionalGlobePoint[]Places with a soft pulse
dotsoptionalnumberDensity of the dot grid. Default 1500. Sea dots are thinned to a third.
seedoptionalnumberSeed of the invented land
durationoptionalnumberSeconds per turn at speed 1. Default 50.
rotateoptionalnumberStart angle in degrees, also the still picture. Default -20.
tiltoptionalnumberTilt of the axis towards the viewer in degrees. Default 16.
sizeoptionalnumberLargest size in px, it shrinks with its container. Default 520.
interactiveoptionalbooleanLeans a few degrees towards a mouse pointer. Default true.
labeloptionalstringAccessible description. Without it the globe is decorative.
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 258 lines
'use client';
import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';
import { PlayToggle, SCENE_CSS, keyframes, rand, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';
/*
* A dotted globe. Every dot is a point on a sphere, projected flat and moved
* with transform only; dots on the far side fade out with opacity. The land
* is invented from a few seeded blobs, no map data of anyone. Arcs are
* dotted great circles lifted off the surface that light up one after
* another. The server renders the globe at its start angle, so without
* JavaScript and with reduced motion it stands still and complete.
*/
export interface GlobePoint {
/** Latitude in degrees, north is positive */
lat: number;
/** Longitude in degrees, east is positive */
lon: number;
}
export interface GlobeArc {
from: GlobePoint;
to: GlobePoint;
}
export interface GlobeProps extends SceneMotionProps {
/** Dotted arcs between two places that light up one after another */
arcs?: GlobeArc[];
/** Places with a soft pulse */
pins?: GlobePoint[];
/** Density of the dot grid. Default 1500. Sea dots are thinned to a third. */
dots?: number;
/** Seed of the invented land */
seed?: number;
/** Seconds per turn at speed 1. Default 50. */
duration?: number;
/** Start angle in degrees, also the still picture. Default -20. */
rotate?: number;
/** Tilt of the axis towards the viewer in degrees. Default 16. */
tilt?: number;
/** Largest size in px, it shrinks with its container. Default 520. */
size?: number;
/** Leans a few degrees towards a mouse pointer. Default true. */
interactive?: boolean;
/** Accessible description. Without it the globe is decorative. */
label?: string;
className?: string;
}
const R = 40; // sphere radius in percent of the size
const DEG = Math.PI / 180;
type V3 = [number, number, number];
const vec = (lat: number, lon: number): V3 => [Math.cos(lat * DEG) * Math.sin(lon * DEG), Math.sin(lat * DEG), Math.cos(lat * DEG) * Math.cos(lon * DEG)];
/** Rotate around the vertical axis by a, then tilt around the horizontal axis by b. Returns screen x, y (y down) and depth z. */
function project(p: V3, a: number, b: number): V3 {
const ca = Math.cos(a);
const sa = Math.sin(a);
const x1 = p[0] * ca + p[2] * sa;
const z1 = -p[0] * sa + p[2] * ca;
const cb = Math.cos(b);
const sb = Math.sin(b);
const y2 = p[1] * cb - z1 * sb;
const z2 = p[1] * sb + z1 * cb;
return [x1, -y2, z2];
}
function landBlobs(seed: number) {
return Array.from({ length: 9 }, (_, i) => ({ c: vec(-50 + rand(seed, i * 3 + 1) * 112, -180 + rand(seed, i * 3 + 2) * 360), r: (15 + rand(seed, i * 3 + 3) * 22) * DEG }));
}
function slerp(a: V3, b: V3, t: number): V3 {
const d = Math.min(1, Math.max(-1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));
const o = Math.acos(d);
if (o < 1e-4) return a;
const s = Math.sin(o);
const k1 = Math.sin((1 - t) * o) / s;
const k2 = Math.sin(t * o) / s;
return [a[0] * k1 + b[0] * k2, a[1] * k1 + b[1] * k2, a[2] * k1 + b[2] * k2];
}
interface Dot {
p: V3;
kind: 'land' | 'sea' | 'arc' | 'pin';
arc?: number;
step?: number;
}
const place = (q: V3) => ({
transform: `translate(${round(q[0] * R, 2)}cqw, ${round(q[1] * R, 2)}cqw)`,
opacity: q[2] <= -0.04 ? 0 : round(Math.min(1, 0.2 + q[2] * 1.15), 3),
});
/**
* A slowly turning dotted globe with arcs and pins.
* <Globe arcs={[{ from: { lat: 52.5, lon: 13.4 }, to: { lat: 40.7, lon: -74 } }]} pins={[…]} />
*/
export function Globe({ arcs = [], pins = [], dots = 1500, seed = 3, duration = 50, rotate = -20, tilt = 16, size = 520, interactive = true, speed = 1, autoPlay = 'in-view', controls = true, label, className }: GlobeProps) {
const sid = useSceneId('kvgl');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const layer = useRef<HTMLDivElement>(null);
const blobs = landBlobs(seed);
const golden = Math.PI * (3 - Math.sqrt(5));
const all: Dot[] = [];
for (let i = 0; i < dots; i++) {
const y = 1 - (2 * (i + 0.5)) / dots;
const rr = Math.sqrt(1 - y * y);
const phi = i * golden;
const p: V3 = [rr * Math.sin(phi), y, rr * Math.cos(phi)];
const near = Math.min(...blobs.map((b) => Math.acos(Math.min(1, p[0] * b.c[0] + p[1] * b.c[1] + p[2] * b.c[2])) / b.r)) + (rand(seed + 11, i) - 0.5) * 0.3;
if (near < 1 && Math.abs(y) < 0.94) all.push({ p, kind: 'land' });
else if (i % 3 === 0) all.push({ p, kind: 'sea' });
}
arcs.forEach((a, k) => {
const A = vec(a.from.lat, a.from.lon);
const B = vec(a.to.lat, a.to.lon);
const n = 26;
for (let s = 0; s <= n; s++) {
const t = s / n;
const q = slerp(A, B, t);
const lift = 1 + 0.2 * Math.sin(Math.PI * t);
all.push({ p: [q[0] * lift, q[1] * lift, q[2] * lift], kind: 'arc', arc: k, step: t });
}
});
pins.forEach((pin) => all.push({ p: vec(pin.lat, pin.lon), kind: 'pin' }));
const a0 = rotate * DEG;
const b0 = tilt * DEG;
useEffect(() => {
const el = layer.current;
const root = play.ref.current;
if (!el || !root || prefersReducedMotion()) return;
const nodes = Array.from(el.children) as HTMLElement[];
let angle = a0;
let tb = b0;
let tx = 0;
let target = { x: 0, y: 0 };
let last = 0;
let frame = 0;
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
if (root.dataset.kv !== 'on') {
last = 0;
return;
}
const dt = last ? Math.min(64, now - last) / 1000 : 0;
last = now;
angle += ((2 * Math.PI) / Math.max(4, duration)) * Math.max(0.05, speed) * dt;
tx += (target.x * 0.35 - tx) * Math.min(1, dt * 4);
tb += (b0 - target.y * 0.25 - tb) * Math.min(1, dt * 4);
for (let i = 0; i < all.length && i < nodes.length; i++) {
const s = place(project(all[i].p, angle + tx, tb));
nodes[i].style.transform = s.transform;
nodes[i].style.opacity = String(s.opacity);
}
};
frame = requestAnimationFrame(tick);
const move = (e: PointerEvent) => {
if (!interactive || e.pointerType !== 'mouse') return;
const r = root.getBoundingClientRect();
target = { x: (e.clientX - r.left) / r.width - 0.5, y: (e.clientY - r.top) / r.height - 0.5 };
};
const leave = () => {
target = { x: 0, y: 0 };
};
root.addEventListener('pointermove', move);
root.addEventListener('pointerleave', leave);
return () => {
cancelAnimationFrame(frame);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerleave', leave);
};
// The dot list follows from these props
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dots, seed, duration, speed, rotate, tilt, interactive, arcs.length, pins.length]);
const gap = 2.4 / Math.max(0.1, speed);
const cycle = round(Math.max(1, arcs.length) * gap + 2 / Math.max(0.1, speed), 2);
const share = ((2.6 / Math.max(0.1, speed)) / cycle) * 100;
const css = `${SCENE_CSS}
${keyframes(`${sid}-arc`, [[0, 'opacity:.16;transform:scale(.6)'], [share * 0.06, 'opacity:.16;transform:scale(.6)'], [share * 0.18, 'opacity:1;transform:scale(1.3)'], [share * 0.55, 'opacity:1;transform:scale(1)'], [share, 'opacity:.16;transform:scale(.6)'], [100, 'opacity:.16;transform:scale(.6)']])}
${keyframes(`${sid}-pulse`, [[0, 'opacity:.75;transform:scale(1)'], [70, 'opacity:0;transform:scale(3.2)'], [100, 'opacity:0;transform:scale(3.2)']])}
.${sid}:not([data-kv="off"]) .${sid}-arc { animation: ${sid}-arc ${cycle}s linear var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-pulse { animation: ${sid}-pulse 2.8s ease-out var(--d) infinite both; }
.${sid} .${sid}-d { position:absolute; left:50%; top:50%; }
.${sid} .${sid}-d > i { position:absolute; border-radius:999px; }
`;
let pinIndex = 0;
return (
<div className={cn('relative mx-auto w-full', className)} style={{ maxWidth: size }}>
<div
ref={play.ref}
data-kv={play.state}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(sid, 'relative aspect-square w-full [container-type:inline-size]')}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<div className="absolute rounded-full" style={{ inset: `${round(50 - R * 1.24, 2)}%`, background: 'radial-gradient(closest-side, color-mix(in oklab, var(--primary) 20%, transparent) 70%, color-mix(in oklab, var(--primary) 6%, transparent) 85%, transparent)' }} />
<div
className="absolute rounded-full"
style={{
inset: `${50 - R}%`,
background: 'radial-gradient(circle at 34% 28%, color-mix(in oklab, var(--primary) 14%, var(--surface-2)), color-mix(in oklab, var(--primary) 5%, var(--background)) 64%, var(--background) 100%)',
boxShadow: 'inset 0 0 0 1px color-mix(in oklab, var(--foreground) 9%, transparent)',
}}
/>
<div ref={layer} className="absolute inset-0">
{all.map((d, i) => {
const s = place(project(d.p, a0, b0));
if (d.kind === 'pin') {
const k = pinIndex++;
return (
<span key={i} className={`${sid}-d`} style={s}>
<i className={`${sid}-pulse`} style={{ width: '3.6cqw', height: '3.6cqw', left: '-1.8cqw', top: '-1.8cqw', border: '0.32cqw solid var(--primary)', opacity: 0, '--d': `${round(k * 0.5, 2)}s` } as React.CSSProperties} />
<i style={{ width: '1.9cqw', height: '1.9cqw', left: '-0.95cqw', top: '-0.95cqw', background: 'var(--primary)', boxShadow: '0 0 0 0.5cqw color-mix(in oklab, var(--primary) 28%, transparent), 0 0 3cqw color-mix(in oklab, var(--primary) 70%, transparent)' }} />
</span>
);
}
const w = d.kind === 'land' ? 0.82 : d.kind === 'arc' ? 0.95 : 0.42;
const bg = d.kind === 'land' ? 'color-mix(in oklab, var(--primary) 50%, var(--foreground))' : d.kind === 'arc' ? 'var(--primary)' : 'color-mix(in oklab, var(--foreground) 24%, transparent)';
return (
<span key={i} className={`${sid}-d`} style={s}>
<i
className={d.kind === 'arc' ? `${sid}-arc` : undefined}
style={{
width: `${w}cqw`,
height: `${w}cqw`,
left: `${round(-w / 2, 3)}cqw`,
top: `${round(-w / 2, 3)}cqw`,
background: bg,
...(d.kind === 'arc' ? ({ boxShadow: '0 0 1.2cqw color-mix(in oklab, var(--primary) 75%, transparent)', '--d': `${round((d.arc ?? 0) * gap + (d.step ?? 0) * 0.9, 2)}s` } as React.CSSProperties) : null),
}}
/>
</span>
);
})}
</div>
<div
className="pointer-events-none absolute rounded-full"
style={{ inset: `${round(50 - R - 0.3, 2)}%`, background: 'radial-gradient(circle at 30% 24%, color-mix(in oklab, #fff 8%, transparent), transparent 44%), radial-gradient(circle at 50% 50%, transparent 64%, color-mix(in oklab, var(--background) 50%, transparent) 100%)' }}
/>
</div>
{controls && (
<div className="absolute bottom-0 right-0">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
</div>
);
}Frequent questions
How do I install Globe?
With aiw add @ai-website/globe. The command puts components/ui/globe.tsx into your project, the code is yours from then on. It brings along motion-scene. It needs no further npm packages.
Which variants does Globe have?
3 variants, Arcs, Pins and Quiet globe.
Can an AI assistant build with Globe?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Globe with aiw_components and install it with aiw_add @ai-website/globe.