Globe

PreviousNext

The Globe component creates beautiful 5kb WebGL Globe

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/globe.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useSpring } from "@react-spring/web";
import createGlobe from "cobe";

interface Marker {
  location: [number, number];
  size: number;
}

interface GlobeProps {
  baseColor?: [number, number, number];
  markerColor?: [number, number, number];
  glowColor?: [number, number, number];
  markers?: Marker[];
  scale?: number;
  className?: string;
  rotateToLocation?: string | [number, number];
  autoRotate?: boolean;
  rotateCities?: string[];
  rotationSpeed?: number;
}

const cityCoordinates: Record<string, [number, number]> = {
  "san francisco": [37.7749, -122.4194],
  "new york": [40.7128, -74.006],
  london: [51.5074, -0.1278],
  tokyo: [35.6762, 139.6503],
  paris: [48.8566, 2.3522],
  moscow: [55.7558, 37.6176],
  dubai: [25.2048, 55.2708],
  singapore: [1.3521, 103.8198],
};

const locationToAngles = (lat: number, long: number): [number, number] => [
  Math.PI - ((long * Math.PI) / 180 - Math.PI / 2),
  (lat * Math.PI) / 180,
];

interface GlobeRenderer {
  destroy: () => void;
}

export default function Globe({
  baseColor = [0.3, 0.3, 0.3],
  markerColor = [0.1, 0.8, 1],
  glowColor = [1, 1, 1],
  markers = [
    { location: [37.7595, -122.4367], size: 0.03 },
    { location: [40.7128, -74.006], size: 0.1 },
  ],
  scale = 1,
  className = "aspect-square w-full max-w-[600px]",
  rotateToLocation,
  autoRotate = true,
  rotateCities = [],
  rotationSpeed = 3000,
}: GlobeProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const pointerInteracting = useRef<number | null>(null);
  const pointerInteractionMovement = useRef(0);
  const focusRef = useRef<[number, number] | null>(null);
  const phiRef = useRef(0);
  const rotationInterval = useRef<NodeJS.Timeout | null>(null);
  const [currentCityIndex, setCurrentCityIndex] = useState(0);
  const globeRef = useRef<GlobeRenderer | null>(null);
  const [isVisible, setIsVisible] = useState(false);

  const [{ r }, api] = useSpring(() => ({
    r: 0,
    config: {
      mass: 1,
      tension: 280,
      friction: 40,
      precision: 0.001,
    },
  }));

  useEffect(() => {
    if (!containerRef.current) return;

    const container = containerRef.current;

    const observer = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
        setIsVisible(entry.isIntersecting);
      },
      { threshold: 0.1 }
    );

    observer.observe(container);

    return () => {
      observer.unobserve(container);
    };
  }, []);

  useEffect(() => {
    if (rotateCities.length === 0) return;

    const rotateToNextCity = () => {
      const nextIndex = (currentCityIndex + 1) % rotateCities.length;
      const city = rotateCities[nextIndex].toLowerCase();
      const coordinates = cityCoordinates[city];

      if (coordinates) {
        focusRef.current = locationToAngles(...coordinates);
        setCurrentCityIndex(nextIndex);
      }
    };

    if (isVisible) {
      const city = rotateCities[currentCityIndex].toLowerCase();
      const coordinates = cityCoordinates[city];
      if (coordinates) {
        focusRef.current = locationToAngles(...coordinates);
      }

      rotationInterval.current = setInterval(rotateToNextCity, rotationSpeed);
    }

    return () => {
      if (rotationInterval.current) {
        clearInterval(rotationInterval.current);
      }
    };
  }, [rotateCities, currentCityIndex, rotationSpeed, isVisible]);

  useEffect(() => {
    if (!rotateToLocation) {
      focusRef.current = null;
      return;
    }

    let coordinates: [number, number];
    if (typeof rotateToLocation === "string") {
      const city = rotateToLocation.toLowerCase();
      coordinates = cityCoordinates[city] || [0, 0];
    } else {
      coordinates = rotateToLocation;
    }

    focusRef.current = locationToAngles(...coordinates);
  }, [rotateToLocation]);

  useEffect(() => {
    if (!isVisible || !canvasRef.current) return;

    let width = canvasRef.current.offsetWidth || 300;
    const doublePi = Math.PI * 2;
    let currentPhi = 0;
    let currentTheta = 0;
    const animationFrame: number | null = null;

    const onResize = () => {
      if (canvasRef.current) {
        width = canvasRef.current.offsetWidth || 300;
      }
    };

    window.addEventListener("resize", onResize);

    try {
      globeRef.current = createGlobe(canvasRef.current, {
        devicePixelRatio: 2,
        width: width * 2,
        height: width * 2,
        phi: 0,
        theta: 0,
        dark: 1,
        diffuse: 1.2,
        mapSamples: 16000,
        mapBrightness: 6,
        baseColor: baseColor || [0.3, 0.3, 0.3],
        markerColor: markerColor || [0.1, 0.8, 1],
        glowColor: glowColor || [1, 1, 1],
        markers: markers || [],
        scale: scale || 1,
        onRender: (state) => {
          if (!state) return;

          if (autoRotate && !pointerInteracting.current && !focusRef.current) {
            phiRef.current += 0.01;
          }

          if (focusRef.current) {
            const [focusPhi, focusTheta] = focusRef.current;
            const distPositive = (focusPhi - currentPhi + doublePi) % doublePi;
            const distNegative = (currentPhi - focusPhi + doublePi) % doublePi;

            currentPhi +=
              distPositive < distNegative
                ? distPositive * 0.08
                : -distNegative * 0.08;
            currentTheta = currentTheta * 0.92 + focusTheta * 0.08;
          } else {
            currentPhi = phiRef.current + r.get();
          }

          state.phi = currentPhi;
          state.theta = focusRef.current ? currentTheta : 0;
          state.width = width * 2;
          state.height = width * 2;
        },
      });

      if (canvasRef.current) {
        setTimeout(() => {
          if (canvasRef.current) canvasRef.current.style.opacity = "1";
        }, 100);
      }
    } catch (error) {
      console.error("Error creating globe:", error);
    }

    return () => {
      if (globeRef.current) {
        globeRef.current.destroy();
        globeRef.current = null;
      }
      window.removeEventListener("resize", onResize);
      if (animationFrame) {
        cancelAnimationFrame(animationFrame);
      }
    };
  }, [
    baseColor,
    markerColor,
    glowColor,
    markers,
    scale,
    r,
    autoRotate,
    isVisible,
  ]);

  return (
    <div ref={containerRef} className={`relative ${className}`}>
      <canvas
        ref={canvasRef}
        onPointerDown={(e) => {
          pointerInteracting.current =
            e.clientX - pointerInteractionMovement.current;
          canvasRef.current?.style?.setProperty("cursor", "grabbing");
        }}
        onPointerUp={() => {
          pointerInteracting.current = null;
          canvasRef.current?.style?.setProperty("cursor", "grab");
        }}
        onPointerOut={() => {
          pointerInteracting.current = null;
          canvasRef.current?.style?.setProperty("cursor", "grab");
        }}
        onMouseMove={(e) => {
          if (pointerInteracting.current !== null) {
            const delta = e.clientX - pointerInteracting.current;
            pointerInteractionMovement.current = delta;
            api.start({ r: delta / 200 });
          }
        }}
        onTouchMove={(e) => {
          if (pointerInteracting.current !== null && e.touches[0]) {
            const delta = e.touches[0].clientX - pointerInteracting.current;
            pointerInteractionMovement.current = delta;
            api.start({ r: delta / 100 });
          }
        }}
        className="w-full h-full cursor-grab opacity-0 transition-opacity duration-1000"
        style={{ contain: "layout paint size" }}
      />
    </div>
  );
}

Installation

npx shadcn@latest add @scrollxui/globe

Usage

import { Globe } from "@/components/globe"
<Globe />