About
Land dots are the points of a Fibonacci sphere that fall on land, tested against a rough sketch of the continents in longitude and latitude (polygons drawn from the shape of the world, enough to place dots a few degrees apart and never a coastline). Dots fade with their facing so the far side disappears; the sea is a dark ball with a fresnel rim and the air a back-faced shell that glows at the limb. Markers are small spheres with a ring that grows and fades; arcs are quadratic curves lifted off the surface in proportion to their length, each with a bright comet. A drag turns the group with inertia; choosing a place eases the yaw to it.
Use it when
- A product with customers, offices or routes in several places.
- A logistics, travel, remittance or network brand.
- A global-reach section on a company page.
Skip it when
- The page needs a real, zoomable map.
- A page that already has a 3D centrepiece.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/dotted-globe - 3Use it:
import { DottedGlobe } from "@/components/vibebox/dotted-globe"; <DottedGlobe markers={[ { id: "manila", label: "Manila", lat: 14.6, lon: 121 }, { id: "dubai", label: "Dubai", lat: 25.2, lon: 55.3 }, ]} arcs={[["manila", "dubai"]]} />
Code
4 filescomponents/vibebox/dotted-globe/dotted-globe.client.tsx
/**
* VibeBox Motion · Dotted Globe
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { canRenderLive, prefersReducedMotion } from "@/lib/vibebox/engine/core";
import { cn } from "@/lib/utils";
import type { GlobeHandle, GlobeMarker } from "./dotted-globe.scene";
export type { GlobeMarker } from "./dotted-globe.scene";
export type DottedGlobeProps = {
/** The places to mark: `{ id, label, lat, lon }`. */
markers: readonly GlobeMarker[];
/** Pairs of marker ids to join with an arc. */
arcs?: ReadonlyArray<readonly [string, string]>;
/** Sea, land dots, the glow at the limb, markers and arcs (any CSS colours). */
colors?: { ocean?: string; land?: string; glow?: string; marker?: string; arc?: string };
/** Turn by itself while it is left alone. */
autoRotate?: boolean;
/** Show the list of places under the globe; choosing one turns the globe to it. */
legend?: boolean;
label?: string;
className?: string;
};
export function DottedGlobe({ markers, arcs, colors, autoRotate = true, legend = true, label = "A globe of dots with marked places; drag to turn it", className }: DottedGlobeProps) {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<GlobeHandle | null>(null);
const [live, setLive] = useState(false);
const [chosen, setChosen] = useState<string | null>(null);
const key = JSON.stringify({ markers, arcs, colors, autoRotate });
useEffect(() => {
const host = hostRef.current;
if (!host || !canRenderLive()) return;
let cancelled = false;
let started = false;
let visible = false;
let releaseTimer = 0;
const start = async () => {
const scene = await import("./dotted-globe.scene");
if (cancelled) return;
handleRef.current = scene.createGlobe(host, { markers, arcs, colors, autoRotate, still: prefersReducedMotion(), onReady: () => setLive(true) });
handleRef.current.setActive(visible);
};
const observer = new IntersectionObserver(
([entry]) => {
visible = Boolean(entry?.isIntersecting);
window.clearTimeout(releaseTimer);
if (visible && (!started || !handleRef.current)) {
started = true;
void start();
}
handleRef.current?.setActive(visible);
if (!visible) {
releaseTimer = window.setTimeout(() => {
handleRef.current?.dispose();
handleRef.current = null;
setLive(false);
}, 8000);
}
},
{ rootMargin: "300px" },
);
observer.observe(host);
return () => {
cancelled = true;
window.clearTimeout(releaseTimer);
observer.disconnect();
handleRef.current?.dispose();
handleRef.current = null;
setLive(false);
};
// The inputs are compared by value through `key`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const base = colors?.land ?? "#7fb2ff";
return (
<div data-vb="dotted-globe" data-live={live ? "true" : "false"} className={cn("flex flex-col items-center gap-4", className)}>
<div className="relative aspect-square w-full max-w-[560px]">
<span
aria-hidden="true"
className={cn("absolute inset-[10%] rounded-full transition-opacity duration-500", live ? "opacity-0" : "opacity-100")}
style={{ background: `radial-gradient(circle at 38% 34%, color-mix(in oklab, ${base} 38%, transparent), ${colors?.ocean ?? "#0a1230"} 62%)` }}
/>
<div ref={hostRef} role="img" aria-label={label} className="absolute inset-0" />
</div>
{legend && markers.length > 0 && (
<ul aria-label="Places" className="flex flex-wrap justify-center gap-1.5">
{markers.map((marker) => (
<li key={marker.id}>
<button
type="button"
aria-pressed={chosen === marker.id}
onClick={() => {
setChosen(marker.id);
handleRef.current?.focus(marker.id);
}}
className="rounded-full border border-border px-3 py-1 text-small text-muted-foreground transition-colors duration-150 hover:border-border-strong hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring aria-pressed:border-foreground aria-pressed:text-foreground"
>
{marker.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}components/vibebox/dotted-globe/dotted-globe.land.ts
/**
* VibeBox Motion · Dotted Globe
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
/*
* A rough sketch of the continents as polygons in longitude and latitude (degrees), drawn from
* the shape of the world rather than any dataset: enough to place dots on land when they are
* a few degrees apart, never to draw a coastline. Polygons are tested with an even-odd ray cast.
*/
export const LAND: Record<string, ReadonlyArray<readonly [number, number]>> = {"northAmerica":[[-168,66],[-162,70],[-156,71],[-141,70],[-128,70],[-115,68],[-95,72],[-85,70],[-82,66],[-94,60],[-93,57],[-85,55],[-80,52],[-79,57],[-76,62],[-70,60],[-64,60],[-60,55],[-56,52],[-60,47],[-66,45],[-70,42],[-74,40],[-76,35],[-81,31],[-80,26],[-83,29],[-90,30],[-97,27],[-97,22],[-92,18],[-87,21],[-88,16],[-84,15],[-83,10],[-79,9],[-77,8],[-80,7],[-85,10],[-92,14],[-97,16],[-105,20],[-110,24],[-112,30],[-115,30],[-110,23],[-117,32],[-121,35],[-124,40],[-124,47],[-130,54],[-135,58],[-145,60],[-152,59],[-158,57],[-165,54],[-158,58],[-162,62],[-166,63]],"greenland":[[-73,78],[-60,82],[-30,83],[-20,80],[-20,70],[-30,68],[-43,60],[-50,62],[-54,67],[-58,76]],"southAmerica":[[-77,8],[-72,12],[-63,11],[-60,8],[-52,5],[-50,0],[-44,-2],[-35,-6],[-35,-9],[-39,-14],[-40,-22],[-48,-26],[-53,-34],[-57,-38],[-62,-39],[-65,-42],[-68,-50],[-69,-54],[-72,-52],[-74,-46],[-73,-38],[-71,-30],[-70,-20],[-76,-14],[-81,-5],[-80,0],[-78,3]],"eurasia":[[-9,37],[-9,43],[-2,44],[0,49],[4,52],[8,54],[10,56],[12,54],[20,55],[24,60],[30,60],[24,66],[30,70],[42,67],[60,69],[70,73],[90,76],[110,77],[130,73],[150,71],[170,70],[180,67],[178,63],[165,60],[160,54],[157,51],[162,56],[155,59],[143,59],[135,54],[141,48],[135,43],[130,42],[127,40],[129,35],[126,35],[125,38],[121,40],[122,37],[119,35],[121,31],[122,28],[117,23],[110,21],[108,17],[109,12],[105,9],[103,5],[104,1],[101,3],[98,8],[98,16],[94,17],[92,22],[88,22],[80,15],[78,8],[73,16],[72,21],[67,24],[61,25],[57,26],[56,24],[51,25],[48,29],[50,30],[56,27],[57,23],[59,22],[55,17],[44,13],[43,16],[39,22],[35,28],[33,31],[35,36],[27,37],[26,40],[29,41],[33,42],[41,41],[41,45],[37,46],[30,45],[28,44],[24,42],[23,38],[19,40],[17,43],[13,45],[12,42],[16,40],[16,38],[10,44],[3,43],[0,40],[-1,37],[-5,36]],"africa":[[-17,15],[-17,21],[-13,28],[-9,32],[-6,36],[10,37],[11,33],[20,31],[32,31],[35,28],[39,19],[43,12],[51,12],[48,5],[41,-2],[40,-10],[40,-16],[35,-24],[32,-29],[27,-34],[19,-35],[17,-29],[12,-17],[13,-9],[9,-1],[9,4],[4,6],[-4,5],[-8,4],[-13,9],[-17,13]],"australia":[[114,-22],[122,-18],[130,-12],[137,-12],[142,-11],[146,-19],[153,-26],[151,-34],[146,-39],[140,-38],[135,-34],[129,-32],[119,-35],[115,-34]],"sumatra":[[95,5],[106,-6],[102,-4],[98,2]],"borneo":[[109,1],[118,7],[119,1],[116,-4],[110,-3]],"java":[[105,-6],[114,-8],[110,-8]],"sulawesi":[[119,-5],[125,1],[121,1]],"newGuinea":[[131,-1],[141,-3],[150,-10],[141,-9],[135,-4]],"luzon":[[120,18],[122,18],[122,13],[120,14]],"mindanao":[[122,8],[126,9],[126,6],[123,7]],"japan":[[130,31],[135,34],[140,36],[142,40],[141,45],[144,43],[140,39],[136,36]],"uk":[[-5,50],[1,51],[2,53],[-2,56],[-5,58],[-6,56],[-4,53]],"ireland":[[-10,52],[-6,54],[-6,52]],"iceland":[[-24,64],[-14,66],[-14,64],[-20,63]],"madagascar":[[44,-25],[49,-15],[50,-13],[44,-17]],"newZealand":[[172,-35],[178,-38],[174,-41],[172,-44],[170,-46],[167,-45],[172,-41]],"sriLanka":[[80,9],[82,7],[80,6]],"cuba":[[-85,22],[-74,20],[-78,22]],"taiwan":[[120,25],[122,25],[121,22]]};
function inside(lon: number, lat: number, polygon: ReadonlyArray<readonly [number, number]>): boolean {
let hit = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i]!;
const [xj, yj] = polygon[j]!;
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) hit = !hit;
}
return hit;
}
/** Is there land under this longitude and latitude (Antarctica is everything south of 72). */
export function isLand(lon: number, lat: number): boolean {
if (lat < -72) return true;
for (const key in LAND) if (inside(lon, lat, LAND[key]!)) return true;
return false;
}components/vibebox/dotted-globe/dotted-globe.scene.ts
/**
* VibeBox Motion · Dotted Globe
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import * as THREE from "three";
import { isLand } from "./dotted-globe.land";
/*
* A globe of dots: land is a few thousand points on a Fibonacci sphere (even to the eye, with no
* poles to bunch at), the sea a dark ball with a rim of light, the air a back-faced shell that
* glows at the limb. Markers pulse; arcs are curves lifted off the surface with a bright comet
* running along them. A drag turns it with inertia, and it turns by itself when left alone.
*/
export type GlobeMarker = { id: string; label: string; lat: number; lon: number };
export type GlobeOptions = {
markers: readonly GlobeMarker[];
/** Pairs of marker ids to join with an arc. */
arcs?: ReadonlyArray<readonly [string, string]>;
colors?: { ocean?: string; land?: string; glow?: string; marker?: string; arc?: string };
autoRotate?: boolean;
still?: boolean;
onReady?: () => void;
};
export type GlobeHandle = {
focus(id: string): void;
setActive(active: boolean): void;
resize(): void;
dispose(): void;
};
const toVector = (lat: number, lon: number, radius = 1) => {
const phi = (lat * Math.PI) / 180;
const theta = (lon * Math.PI) / 180;
return new THREE.Vector3(Math.cos(phi) * Math.sin(theta) * radius, Math.sin(phi) * radius, Math.cos(phi) * Math.cos(theta) * radius);
};
export function createGlobe(host: HTMLElement, options: GlobeOptions): GlobeHandle {
const colors = { ocean: "#0a1230", land: "#7fb2ff", glow: "#4a7bff", marker: "#ffd27a", arc: "#ff6fb5", ...options.colors };
const still = Boolean(options.still);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.cssText = "display:block;width:100%;height:100%;touch-action:pan-y;cursor:grab;";
canvas.setAttribute("aria-hidden", "true");
host.appendChild(canvas);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 20);
camera.position.set(0, 0, 4.6);
const globe = new THREE.Group();
scene.add(globe);
// The sea: a dark ball lit by a rim.
const sea = new THREE.Mesh(
new THREE.SphereGeometry(0.985, 48, 32),
new THREE.ShaderMaterial({
uniforms: { uColor: { value: new THREE.Color(colors.ocean) }, uRim: { value: new THREE.Color(colors.glow) } },
vertexShader: "varying vec3 vN; varying vec3 vV; void main(){ vec4 mv = modelViewMatrix * vec4(position,1.0); vN = normalize(normalMatrix * normal); vV = normalize(-mv.xyz); gl_Position = projectionMatrix * mv; }",
fragmentShader: "uniform vec3 uColor; uniform vec3 uRim; varying vec3 vN; varying vec3 vV; void main(){ float f = pow(1.0 - clamp(dot(vN, vV), 0.0, 1.0), 2.6); gl_FragColor = vec4(uColor + uRim * f * 0.55, 1.0); }",
}),
);
globe.add(sea);
// The air: a shell that glows only at the limb.
const air = new THREE.Mesh(
new THREE.SphereGeometry(1.24, 48, 32),
new THREE.ShaderMaterial({
uniforms: { uGlow: { value: new THREE.Color(colors.glow) } },
vertexShader: "varying vec3 vN; varying vec3 vV; void main(){ vec4 mv = modelViewMatrix * vec4(position,1.0); vN = normalize(normalMatrix * normal); vV = normalize(-mv.xyz); gl_Position = projectionMatrix * mv; }",
fragmentShader: "uniform vec3 uGlow; varying vec3 vN; varying vec3 vV; void main(){ float g = clamp(abs(dot(vN, vV)) / 0.6, 0.0, 1.0); float rim = pow(g, 2.6); gl_FragColor = vec4(uGlow, rim * 0.62); }",
side: THREE.BackSide,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
}),
);
scene.add(air);
// The land: dots on a Fibonacci sphere that fall on land.
const total = 8200;
const land: number[] = [];
for (let i = 0; i < total; i++) {
const y = 1 - (i / (total - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const theta = i * Math.PI * (3 - Math.sqrt(5));
const lat = (Math.asin(y) * 180) / Math.PI;
const lon = ((Math.atan2(Math.cos(theta) * r, Math.sin(theta) * r) * 180) / Math.PI);
const x = Math.sin(theta) * r;
const z = Math.cos(theta) * r;
// Longitude from the vector (x east, z toward lon 0).
const longitude = (Math.atan2(x, z) * 180) / Math.PI;
void lon;
if (isLand(longitude, lat)) land.push(x, y, z);
}
const landGeometry = new THREE.BufferGeometry();
landGeometry.setAttribute("position", new THREE.Float32BufferAttribute(land, 3));
const landMaterial = new THREE.ShaderMaterial({
uniforms: { uColor: { value: new THREE.Color(colors.land) }, uSize: { value: 1 } },
vertexShader:
"uniform float uSize; varying float vFront; void main(){ vec4 mv = modelViewMatrix * vec4(position * 1.0, 1.0); vec3 n = normalize(normalMatrix * position); vFront = clamp(dot(n, normalize(-mv.xyz)) * 1.4, 0.0, 1.0); gl_Position = projectionMatrix * mv; gl_PointSize = uSize * (3.2 / -mv.z) * 3.6; }",
fragmentShader:
"uniform vec3 uColor; varying float vFront; void main(){ float d = length(gl_PointCoord - 0.5); float a = smoothstep(0.5, 0.15, d) * vFront; if (a < 0.02) discard; gl_FragColor = vec4(uColor, a); }",
transparent: true,
depthWrite: false,
});
const points = new THREE.Points(landGeometry, landMaterial);
globe.add(points);
// Markers and their rings.
const markerGroup = new THREE.Group();
globe.add(markerGroup);
const markerById = new Map<string, GlobeMarker & { position: THREE.Vector3 }>();
const markerMaterial = new THREE.MeshBasicMaterial({ color: colors.marker });
const ringMaterial = new THREE.MeshBasicMaterial({ color: colors.marker, transparent: true, opacity: 0.8, side: THREE.DoubleSide, depthWrite: false });
const markerGeometry = new THREE.SphereGeometry(0.018, 12, 8);
const ringGeometry = new THREE.RingGeometry(0.03, 0.036, 32);
const rings: THREE.Mesh[] = [];
for (const marker of options.markers) {
const position = toVector(marker.lat, marker.lon, 1.003);
markerById.set(marker.id, { ...marker, position });
const dot = new THREE.Mesh(markerGeometry, markerMaterial);
dot.position.copy(position);
markerGroup.add(dot);
const ring = new THREE.Mesh(ringGeometry, ringMaterial.clone());
ring.position.copy(position);
ring.lookAt(position.clone().multiplyScalar(2));
markerGroup.add(ring);
rings.push(ring);
}
// Arcs: curves lifted off the surface, with a comet along each.
const arcMaterial = new THREE.LineBasicMaterial({ color: colors.arc, transparent: true, opacity: 0.55 });
const comets: { mesh: THREE.Mesh; curve: THREE.QuadraticBezierCurve3; offset: number }[] = [];
const cometGeometry = new THREE.SphereGeometry(0.014, 8, 6);
const cometMaterial = new THREE.MeshBasicMaterial({ color: colors.arc });
const arcGeometries: THREE.BufferGeometry[] = [];
(options.arcs ?? []).forEach(([fromId, toId], index) => {
const a = markerById.get(fromId)?.position;
const b = markerById.get(toId)?.position;
if (!a || !b) return;
const mid = a.clone().add(b).multiplyScalar(0.5);
const lift = 1 + a.distanceTo(b) * 0.38;
mid.normalize().multiplyScalar(lift);
const curve = new THREE.QuadraticBezierCurve3(a.clone(), mid, b.clone());
const geometry = new THREE.BufferGeometry().setFromPoints(curve.getPoints(48));
arcGeometries.push(geometry);
globe.add(new THREE.Line(geometry, arcMaterial));
const comet = new THREE.Mesh(cometGeometry, cometMaterial);
globe.add(comet);
comets.push({ mesh: comet, curve, offset: index * 0.23 });
});
// ---- layout and rotation ----
const fit = () => {
const width = Math.max(1, host.clientWidth);
const height = Math.max(1, host.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, width < 640 ? 1.5 : 1.75));
renderer.setSize(width, height, false);
camera.aspect = width / height;
const fov = THREE.MathUtils.degToRad(camera.fov);
const forHeight = 1.32 / Math.tan(fov / 2);
const forWidth = 1.32 / (Math.tan(fov / 2) * camera.aspect);
camera.position.z = Math.max(forHeight, forWidth);
camera.updateProjectionMatrix();
landMaterial.uniforms.uSize!.value = (Math.min(width, height) / 520) * renderer.getPixelRatio();
};
let yaw = -0.6;
let pitch = 0.28;
let yawVelocity = 0;
let targetYaw: number | null = null;
let dragging = false;
let lastX = 0;
let lastY = 0;
let idleSince = 0;
let raf = 0;
let active = true;
let last = performance.now();
const autoRotate = options.autoRotate !== false && !still;
const render = (time: number) => {
raf = 0;
const dt = Math.min(0.05, (time - last) / 1000);
last = time;
if (!dragging) {
if (targetYaw !== null) {
const delta = Math.atan2(Math.sin(targetYaw - yaw), Math.cos(targetYaw - yaw));
yaw += delta * (1 - Math.exp(-dt * 4));
if (Math.abs(delta) < 0.002) targetYaw = null;
} else {
yaw += yawVelocity * dt;
yawVelocity *= Math.exp(-dt * 2.2);
if (autoRotate && time - idleSince > 2500) yaw += dt * 0.12;
}
}
globe.rotation.set(pitch, yaw, 0);
// The pulse of every marker, offset from its neighbour.
rings.forEach((ring, index) => {
const phase = (time / 1000 / 2.2 + index * 0.31) % 1;
ring.scale.setScalar(1 + phase * 2.6);
(ring.material as THREE.MeshBasicMaterial).opacity = 0.7 * (1 - phase);
});
comets.forEach((comet) => {
const t = ((time / 1000) * 0.22 + comet.offset) % 1;
comet.mesh.position.copy(comet.curve.getPoint(t));
});
renderer.render(scene, camera);
if (options.onReady) {
options.onReady();
options.onReady = undefined;
}
if (active && !still) raf = requestAnimationFrame(render);
};
const start = () => {
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(render);
}
};
const onDown = (event: PointerEvent) => {
dragging = true;
lastX = event.clientX;
lastY = event.clientY;
yawVelocity = 0;
targetYaw = null;
idleSince = performance.now();
canvas.setPointerCapture(event.pointerId);
canvas.style.cursor = "grabbing";
if (still) start();
};
const onMove = (event: PointerEvent) => {
if (!dragging) return;
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
yaw += dx * 0.006;
pitch = Math.max(-0.7, Math.min(0.9, pitch + dy * 0.004));
yawVelocity = dx * 0.006 * 60 * 0.5;
idleSince = performance.now();
if (still) start();
};
const onUp = (event: PointerEvent) => {
dragging = false;
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
canvas.style.cursor = "grab";
idleSince = performance.now();
};
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
const resize = new ResizeObserver(() => {
fit();
if (still) start();
});
resize.observe(host);
fit();
start();
idleSince = performance.now();
return {
focus(id) {
const marker = markerById.get(id);
if (!marker) return;
targetYaw = -(marker.lon * Math.PI) / 180;
pitch = Math.max(-0.5, Math.min(0.7, (marker.lat * Math.PI) / 180 * 0.7));
idleSince = performance.now();
start();
},
setActive(next) {
active = next;
if (next) start();
else {
cancelAnimationFrame(raf);
raf = 0;
}
},
resize() {
fit();
start();
},
dispose() {
cancelAnimationFrame(raf);
resize.disconnect();
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
[sea, air].forEach((mesh) => {
mesh.geometry.dispose();
(mesh.material as THREE.Material).dispose();
});
landGeometry.dispose();
landMaterial.dispose();
markerGeometry.dispose();
markerMaterial.dispose();
ringGeometry.dispose();
rings.forEach((ring) => (ring.material as THREE.Material).dispose());
ringMaterial.dispose();
arcGeometries.forEach((geometry) => geometry.dispose());
arcMaterial.dispose();
cometGeometry.dispose();
cometMaterial.dispose();
renderer.dispose();
canvas.remove();
},
};
}components/vibebox/dotted-globe/index.tsx
/**
* VibeBox Motion · Dotted Globe
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { DottedGlobe, type DottedGlobeProps, type GlobeMarker } from "./dotted-globe.client";Props
| Prop | Type | Default | Description |
|---|---|---|---|
| markers * | GlobeMarker[] | — | The places to mark: { id, label, lat, lon }. |
| arcs | [string, string][] | — | Pairs of marker ids to join with an arc. |
| colors | { ocean?: string; land?: string; glow?: string; marker?: string; arc?: string } | — | Sea, land dots, the glow at the limb, markers and arcs. |
| autoRotate | boolean | true | Turn by itself while it is left alone. |
| legend | boolean | true | Show the list of places; choosing one turns the globe to it. |
| label | string | — | Accessible name. |
| className | string | — | Merged with cn(). |
Accessibility and motion
- Keyboard
- The places are buttons: Tab to one and press Enter to turn the globe to it.
- Screen readers
- The globe is an image named by
label; the places are a list of buttons. - Touch
- Drag to turn it; vertical scrolling stays available around it.
- Reduced motion
- One still frame of the globe; it does not turn by itself, but a drag or a place still turns it.
Edge cases and limits
- The continents are a sketch: do not use it where geography must be exact.
- Arcs between near-antipodal places pass through the globe; avoid those pairs.
- Very small sizes (under 220 px) lose the dots; show the legend instead.
Related

Brochure
FreeA 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.
3D · three.js

Coin
FreeA struck coin with a reeded edge, your mark and words on its face; hold the pointer on it and it turns over.
3D · three.js

Gift Box
FreeA wrapped gift whose lid swings open on hover, with a warm light and a burst of confetti.
3D · three.js