Skip to content

FreeIn reviewv1.0.0

Dotted Globe

A globe of dots with pulsing markers and arcs; drag to turn it, choose a place to spin to it.
npx shadcn@latest add @vibebox/dotted-globe

Controls

<DottedGlobe />

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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/dotted-globe
  3. 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 files
components/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

PropTypeDefaultDescription
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.
autoRotatebooleantrueTurn by itself while it is left alone.
legendbooleantrueShow the list of places; choosing one turns the globe to it.
labelstring—Accessible name.
classNamestring—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.
  • An open 3D brochure on a desk with a page turning and its corner lifted

    A 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.

    3D · three.js

  • A gold coin with a V in the middle and words around its rim, turning on its edge

    Coin

    Free

    A 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

  • A pink gift box with a gold ribbon and bow, its lid lifting with confetti

    A wrapped gift whose lid swings open on hover, with a warm light and a burst of confetti.

    3D · three.js

↑ ↓ to move↵ to openesc to close