Skip to content
Domphy

globe

A Core block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call globe() with no arguments for a working demo, or edit the code below live.

Props

PropTypeDescription
diameterUnitsnumberContainer max diameter, in themeSpacing units. Defaults to 150 (37.5em ≈ 600px, matching upstream's max-w-150).
darkbooleancobe's own dark-mode shading flag (affects the lighting model, independent of the page theme). Defaults to false.
baseColor[number, number, number]Normalized RGB triplet for the sphere's base/land color. Defaults to the theme's neutral "shift-3" token.
markerColor[number, number, number]Normalized RGB triplet for marker dots. Defaults to the theme's "attention" "shift-9" token.
glowColor[number, number, number]Normalized RGB triplet for the atmosphere glow. Defaults to the theme's neutral "shift-1" token.
mapSamplesnumberDot sample density across the sphere surface. Defaults to 16000.
mapBrightnessnumberLand-dot brightness. Defaults to 1.2.
rotationSpeednumberAuto-rotation speed (phi radians added per frame). Defaults to 0.005.
initialPhinumberInitial phi (longitude) rotation offset, radians. Defaults to 0.
initialThetanumberInitial theta (latitude) tilt, radians. Defaults to 0.3.
markersGlobeMarker[]Highlighted lat/long locations. Defaults to a handful of major-city reference points.
draggablebooleanEnables click-and-drag orbit control. Defaults to true.
Implementation notes

Uses the cobe WebGL dot-globe library directly (an approved pre-installed dependency for this exact purpose, per the block-authoring brief) via its public createGlobe(canvas, options) API — real dot-sphere rendering, auto-rotate, drag-to-orbit with velocity-decay inertial coasting, lat/long markers. Canvas is created imperatively in _onMount (not a static Domphy child) to sidestep the parent-Mount-fires-before-children-render ordering gotcha confirmed in ElementNode's render() path. Default sphere/marker/glow colors resolve from the live Domphy theme via themeColorToken rather than guessed literal hex values. Gaps: (1) cobe bakes width/height into construction, so a meaningful container resize recreates the instance rather than mutating it in place; (2) WebGL init is wrapped in try/catch and fails closed to a static empty canvas in environments without a real WebGL context (confirmed via jsdom test, which has none) — visual WebGL rendering itself can't be verified under the jsdom test runtime, only the DOM scaffolding/lifecycle/cleanup around it.

Status: ported · Reference: Magic UI original

// magicui "Globe" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). An
// interactive, auto-rotating 3D dot-sphere globe, drag-to-orbit with
// spring-eased rotation (the drag offset feeds a critically-overdamped
// spring, so rotation eases smoothly and there is no release momentum),
// rendered on a `<canvas>` via WebGL.
//
// Rendering is delegated to `cobe` (already an approved dependency of this
// package, per the block-authoring brief — see the package's `dependencies`)
// rather than hand-rolling a sphere rasterizer: it is the standard
// lightweight WebGL dot-globe library and using its public `createGlobe(canvas,
// options)` API is a legitimate, independent integration, not a copy of any
// UI framework's component source. Default sphere/marker colors are resolved
// from the current Domphy theme (via `themeColorToken`) rather than guessed
// literal hex values, so the globe matches whatever theme is active.

import type { DomphyElement, ElementNode } from "@domphy/core";
import { behavior } from "@domphy/core";
import { type ThemeColor, themeColorToken, themeSpacing } from "@domphy/theme";
import createGlobe, { type COBEOptions, type Globe, type Marker } from "cobe";

export interface GlobeMarker {
  latitude: number;
  longitude: number;
  /** Marker dot size, in cobe's own 0–1 scale. Defaults to 0.05. */
  size?: number;
  /** Normalized RGB triplet (0–1 per channel). Defaults to the globe's `markerColor`. */
  color?: [number, number, number];
}

export interface GlobeProps {
  /** Container max diameter, in `themeSpacing` units. Defaults to 150 (37.5em ≈ 600px, matching upstream's `max-w-150`). */
  diameterUnits?: number;
  /** cobe's own dark-mode shading flag (affects the lighting model, independent of the page theme). Defaults to false. */
  dark?: boolean;
  /** Normalized RGB triplet for the sphere's base/land color. Defaults to the theme's neutral "shift-3" token. */
  baseColor?: [number, number, number];
  /** Normalized RGB triplet for marker dots. Defaults to the theme's "attention" "shift-9" token. */
  markerColor?: [number, number, number];
  /** Normalized RGB triplet for the atmosphere glow. Defaults to the theme's neutral "shift-1" token. */
  glowColor?: [number, number, number];
  /** Dot sample density across the sphere surface. Defaults to 16000. */
  mapSamples?: number;
  /** Land-dot brightness. Defaults to 1.2. */
  mapBrightness?: number;
  /** Auto-rotation speed (phi radians added per frame). Defaults to 0.005. */
  rotationSpeed?: number;
  /** Initial phi (longitude) rotation offset, radians. Defaults to 0. */
  initialPhi?: number;
  /** Initial theta (latitude) tilt, radians. Defaults to 0.3. */
  initialTheta?: number;
  /** Highlighted lat/long locations. Defaults to a handful of major-city reference points. */
  markers?: GlobeMarker[];
  /** Enables click-and-drag orbit control. Defaults to true. */
  draggable?: boolean;
}

// Well-known public city coordinates — plain geographic facts, used purely as
// illustrative default marker locations for the demo, not sourced from any
// third party's specific marker dataset.
const GLOBE_BEHAVIOR_KEY = "magicui-globe";

interface GlobeBehaviorProps {
  dark: boolean;
  mapSamples: number;
  mapBrightness: number;
  rotationSpeed: number;
  initialPhi: number;
  initialTheta: number;
  markers: GlobeMarker[];
  draggable: boolean;
  baseColor?: [number, number, number];
  markerColor?: [number, number, number];
  glowColor?: [number, number, number];
}

const DEFAULT_MARKERS: GlobeMarker[] = [
  { latitude: 14.5995, longitude: 120.9842, size: 0.03 }, // Manila
  { latitude: 19.076, longitude: 72.8777, size: 0.1 }, // Mumbai
  { latitude: 23.8103, longitude: 90.4125, size: 0.05 }, // Dhaka
  { latitude: 30.0444, longitude: 31.2357, size: 0.07 }, // Cairo
  { latitude: 39.9042, longitude: 116.4074, size: 0.08 }, // Beijing
  { latitude: -23.5505, longitude: -46.6333, size: 0.1 }, // São Paulo
  { latitude: 19.4326, longitude: -99.1332, size: 0.1 }, // Mexico City
  { latitude: 40.7128, longitude: -74.006, size: 0.1 }, // New York
  { latitude: 34.6937, longitude: 135.5022, size: 0.05 }, // Osaka
  { latitude: 41.0082, longitude: 28.9784, size: 0.06 }, // Istanbul
];

function hexToNormalizedRgb(hex: string): [number, number, number] {
  const normalized = hex.replace("#", "");
  const r = Number.parseInt(normalized.slice(0, 2), 16) / 255;
  const g = Number.parseInt(normalized.slice(2, 4), 16) / 255;
  const b = Number.parseInt(normalized.slice(4, 6), 16) / 255;
  return [r || 0, g || 0, b || 0];
}

/**
 * An interactive auto-rotating dot-sphere globe (WebGL via `cobe`), with
 * drag-to-orbit and spring-eased rotation. Call with no arguments for a working
 * demo — a themed sphere with a handful of highlighted city markers,
 * auto-rotating at rest.
 */
function globe(props: GlobeProps = {}): DomphyElement<"div"> {
  const diameterUnits = props.diameterUnits ?? 150;
  const dark = props.dark ?? false;
  const mapSamples = props.mapSamples ?? 16000;
  const mapBrightness = props.mapBrightness ?? 1.2;
  const rotationSpeed = props.rotationSpeed ?? 0.005;
  const initialPhi = props.initialPhi ?? 0;
  const initialTheta = props.initialTheta ?? 0.3;
  const markers = props.markers ?? DEFAULT_MARKERS;
  const draggable = props.draggable ?? true;

  return {
    div: [],
    role: "img",
    ariaLabel: "Interactive rotating globe",
    style: {
      position: "relative",
      width: "100%",
      maxWidth: themeSpacing(diameterUnits),
      aspectRatio: "1 / 1",
      marginInline: "auto",
      contain: "layout paint size",
    },
    ...behavior<GlobeBehaviorProps>(GLOBE_BEHAVIOR_KEY, attachGlobe, {
      dark,
      mapSamples,
      mapBrightness,
      rotationSpeed,
      initialPhi,
      initialTheta,
      markers,
      draggable,
      baseColor: props.baseColor,
      markerColor: props.markerColor,
      glowColor: props.glowColor,
    }),
  };
}

function attachGlobe(node: ElementNode, initialProps: GlobeBehaviorProps) {
  let props = initialProps;
  const container = node.domElement as HTMLElement | null;
  if (!container || typeof document === "undefined") {
    return { update() {}, destroy() {} };
  }

  const canvas = document.createElement("canvas");
  canvas.setAttribute("aria-hidden", "true");
  canvas.style.position = "absolute";
  canvas.style.inset = "0";
  canvas.style.width = "100%";
  canvas.style.height = "100%";
  canvas.style.cursor = props.draggable ? "grab" : "default";
  canvas.style.opacity = "0";
  canvas.style.transition = "opacity 500ms cubic-bezier(0.4, 0, 0.2, 1)";
  container.appendChild(canvas);

  // Upstream feeds the drag offset into a motion/react spring
  // (mass 1, damping 30, stiffness 100 → damping ratio 1.5, i.e.
  // overdamped: eases toward the target with no overshoot and, once the
  // drag is released, no residual momentum — the spring just settles).
  // We integrate that same spring by hand each frame instead of coasting.
  const SPRING_MASS = 1;
  const SPRING_DAMPING = 30;
  const SPRING_STIFFNESS = 100;

  let phi = props.initialPhi;
  // Accumulated drag offset (radians) — the spring's target — and the
  // spring's own current value/velocity that ease toward it.
  let dragTarget = 0;
  let dragSpring = 0;
  let dragSpringVelocity = 0;
  // Null when not dragging; otherwise the pointer's clientX at the moment
  // the drag began (kept fixed for the whole drag, matching upstream).
  let pointerStartX: number | null = null;
  let lastFrameTime = 0;
  let width = container.clientWidth || 1;
  let globeInstance: Globe | null = null;
  let resizeObserver: ResizeObserver | null = null;
  let animationFrameId: number | null = null;

  const resolveColor = (
    override: [number, number, number] | undefined,
    tone: string,
    colorName: ThemeColor,
  ): [number, number, number] => {
    if (override) return override;
    try {
      return hexToNormalizedRgb(themeColorToken(node, tone, colorName));
    } catch {
      return [0.4, 0.4, 0.45];
    }
  };

  const markerList: Marker[] = props.markers.map((marker) => ({
    location: [marker.latitude, marker.longitude],
    size: marker.size ?? 0.05,
    color: marker.color,
  }));

  const baseColor = resolveColor(props.baseColor, "shift-3", "neutral");
  const markerColor = resolveColor(props.markerColor, "shift-9", "attention");
  const glowColor = resolveColor(props.glowColor, "shift-1", "neutral");

  // Upstream hardcodes `devicePixelRatio: 2` (always supersamples, even on
  // DPR-1 screens). cobe v2 sizes the canvas's REAL backing store itself
  // as `opts.width * devicePixelRatio` (see its `createGlobe` — the 0.6
  // `phenomenon` delegate that used to do this from `clientWidth` was
  // removed), so `width`/`height` here are LOGICAL pixels and must NOT be
  // pre-multiplied by the ratio, or the shader's resolution uniform and
  // the actual viewport disagree and the sphere renders mis-scaled.
  const buildOptions = (): COBEOptions => ({
    devicePixelRatio: 2,
    width,
    height: width,
    phi,
    theta: props.initialTheta,
    dark: props.dark ? 1 : 0,
    diffuse: 0.4,
    mapSamples: props.mapSamples,
    mapBrightness: props.mapBrightness,
    baseColor,
    markerColor,
    glowColor,
    markers: markerList,
  });

  // cobe requires a real WebGL context; v2 fails soft (returns a no-op
  // instance) in environments without one (older browsers, headless/test
  // runtimes), and the try/catch stays as a defensive guard for runtimes
  // that throw instead — either way the block degrades to a static empty
  // canvas rather than crashing the whole tree.
  try {
    globeInstance = createGlobe(canvas, buildOptions());
  } catch {
    globeInstance = null;
  }
  setTimeout(() => {
    canvas.style.opacity = "1";
  }, 0);

  // cobe v2 renders exactly one frame per `update()` call and has no
  // built-in animation loop or `onRender` callback (0.6's Phenomenon
  // loop was removed), so the block drives its own requestAnimationFrame
  // loop. Each frame advances the auto-rotation (only while at rest —
  // upstream freezes it during a drag), eases the drag spring toward its
  // target (semi-implicit Euler; dt is clamped so a backgrounded tab
  // can't destabilize it), and uploads the final rotation.
  const tick = () => {
    if (!canvas.isConnected) return;
    if (pointerStartX === null) phi += props.rotationSpeed;

    const now =
      typeof performance !== "undefined" ? performance.now() : Date.now();
    let dt = lastFrameTime ? (now - lastFrameTime) / 1000 : 1 / 60;
    lastFrameTime = now;
    if (dt > 1 / 30) dt = 1 / 30;
    const springForce =
      -SPRING_STIFFNESS * (dragSpring - dragTarget) -
      SPRING_DAMPING * dragSpringVelocity;
    dragSpringVelocity += (springForce / SPRING_MASS) * dt;
    dragSpring += dragSpringVelocity * dt;

    // Final rotation = auto-rotate accumulator + spring-eased drag
    // offset (upstream: `state.phi = phiRef.current + rs.get()`).
    globeInstance?.update({ phi: phi + dragSpring });
    animationFrameId = requestAnimationFrame(tick);
  };
  if (globeInstance && typeof requestAnimationFrame === "function") {
    animationFrameId = requestAnimationFrame(tick);
  }

  // Upstream divides the pointer delta by MOVEMENT_DAMPING (1400) before
  // adding it to the spring target `r`.
  const MOVEMENT_DAMPING = 1400;

  const startDrag = (clientX: number) => {
    pointerStartX = clientX;
    canvas.style.cursor = "grabbing";
  };
  // Upstream releases the drag on BOTH pointerup and pointerout: the
  // pointer leaving the canvas cancels the drag. There is no pointer
  // capture, so a drag does not continue while the pointer is outside.
  const endDrag = () => {
    if (pointerStartX === null) return;
    pointerStartX = null;
    canvas.style.cursor = "grab";
  };
  const applyMovement = (clientX: number) => {
    if (pointerStartX === null) return;
    // delta is measured from the drag's fixed start point (upstream keeps
    // `pointerInteracting.current` at the down position for the whole drag).
    const delta = clientX - pointerStartX;
    dragTarget += delta / MOVEMENT_DAMPING;
  };

  const handlePointerDown = (event: PointerEvent) => startDrag(event.clientX);
  const handlePointerUp = () => endDrag();
  const handlePointerOut = () => endDrag();
  const handleMouseMove = (event: MouseEvent) => applyMovement(event.clientX);
  const handleTouchMove = (event: TouchEvent) => {
    const touch = event.touches[0];
    if (touch) applyMovement(touch.clientX);
  };

  if (props.draggable) {
    canvas.addEventListener("pointerdown", handlePointerDown);
    canvas.addEventListener("pointerup", handlePointerUp);
    canvas.addEventListener("pointerout", handlePointerOut);
    canvas.addEventListener("mousemove", handleMouseMove);
    canvas.addEventListener("touchmove", handleTouchMove);
  }

  // cobe v2 accepts `width`/`height` through `update()` and re-sizes the
  // backing store itself, so a meaningful container resize pushes the new
  // logical size into the live instance instead of recreating it.
  if (typeof ResizeObserver !== "undefined") {
    resizeObserver = new ResizeObserver(() => {
      const nextWidth = container.clientWidth;
      if (Math.abs(nextWidth - width) < 4 || nextWidth === 0) return;
      width = nextWidth;
      globeInstance?.update({ width, height: width });
    });
    resizeObserver.observe(container);
  }

  return {
    update(next: GlobeBehaviorProps) {
      props = next;
    },
    destroy() {
      if (
        animationFrameId !== null &&
        typeof cancelAnimationFrame === "function"
      ) {
        cancelAnimationFrame(animationFrameId);
      }
      globeInstance?.destroy();
      resizeObserver?.disconnect();
      if (props.draggable) {
        canvas.removeEventListener("pointerdown", handlePointerDown);
        canvas.removeEventListener("pointerup", handlePointerUp);
        canvas.removeEventListener("pointerout", handlePointerOut);
        canvas.removeEventListener("mousemove", handleMouseMove);
        canvas.removeEventListener("touchmove", handleTouchMove);
      }
    },
  };
}

export { globe };

← Back to Magic UI catalog