Menu

Interactive 3D Globe

A stunning, interactive WebGL globe for visualizing global data, server locations, or user activity.

Preview

Live interactive preview.

Installation

Add this component to your project using the CLI:

terminal
npx -y vui-registry-cli-v1@latest add interactive-3d-globe

Source Code

interactive-3d-globe.tsx
"use client"

import createGlobe from "cobe"
import { useEffect, useRef, useState } from "react"
import { motion } from "framer-motion"
import { MapPin, RotateCw } from "lucide-react"
import { cn } from "@/lib/utils"

export default function Interactive3dGlobe() {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const pointerInteracting = useRef<number | null>(null)
  const pointerInteractionMovement = useRef(0)
  const rRef = useRef(0)

  const [activeRegion, setActiveRegion] = useState("Americas")
  const [width, setWidth] = useState(0)
  const targetPhiRef = useRef(0)

  const [isAutoRotating, setIsAutoRotating] = useState(true)
  const isAutoRotatingRef = useRef(true)

  const regions: Record<string, [number, number]> = {
    "Americas": [40, -100],
    "Europe": [50, 10],
    "Asia": [30, 100],
    "Africa": [10, 20],
    "India": [20, 77],
  }

  const handleRegionChange = (name: string, coords: [number, number]) => {
    setActiveRegion(name)
    setIsAutoRotating(false)
    isAutoRotatingRef.current = false
    const target = (coords[1] * Math.PI) / 180
    targetPhiRef.current = target
    rRef.current = 0
    pointerInteractionMovement.current = 0
  }

  const updateWidth = () => {
    if (canvasRef.current) {
      setWidth(canvasRef.current.offsetWidth)
    }
  }

  useEffect(() => {
    window.addEventListener("resize", updateWidth)
    updateWidth()
    return () => window.removeEventListener("resize", updateWidth)
  }, [])

  useEffect(() => {
    let currentPhi = 0
    let width = 0
    const onResize = () => canvasRef.current && (width = canvasRef.current.offsetWidth)
    window.addEventListener("resize", onResize)
    onResize()

    if (!canvasRef.current) return

    const globe = createGlobe(canvasRef.current, {
      devicePixelRatio: 2,
      width: width * 2,
      height: width * 2,
      phi: 0,
      theta: 0.3,
      dark: 1,
      diffuse: 1.2,
      mapSamples: 16000,
      mapBrightness: 6,
      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.05 },
        { location: [40.7128, -74.006], size: 0.05 },
        { location: [51.5074, -0.1278], size: 0.05 },
        { location: [35.6762, 139.6503], size: 0.05 },
        { location: [28.6139, 77.2090], size: 0.05 },
        { location: [-33.8688, 151.2093], size: 0.05 },
        { location: [30.0444, 31.2357], size: 0.05 },
        { location: [55.7558, 37.6173], size: 0.05 },
        { location: [-23.5505, -46.6333], size: 0.05 },
      ],
      onRender: (state) => {
        if (!pointerInteracting.current) {
          if (isAutoRotatingRef.current) {
            currentPhi += 0.003
            targetPhiRef.current = currentPhi
          } else {
            const dist = targetPhiRef.current - currentPhi
            currentPhi += dist * 0.08
          }
        }
        state.phi = currentPhi + rRef.current
        state.width = width * 2
        state.height = width * 2
      },
    })

    setTimeout(() => {
      if (canvasRef.current) canvasRef.current.style.opacity = "1"
    })
    return () => globe.destroy()
  }, [])

  return (
    <div className="relative flex h-[600px] w-full flex-col items-center justify-center overflow-hidden bg-background font-sans">
      <div
        className="absolute inset-0 pointer-events-none"
        style={{
          background:
            "radial-gradient(ellipse at center, hsl(var(--accent)/0.08) 0%, hsl(var(--foreground)/0.03) 45%, transparent 75%)",
        }}
      />

      <div className="relative z-0 h-full w-full flex items-center justify-center">
        <canvas
          ref={canvasRef}
          style={{ width: 600, height: 600, maxWidth: "100%", aspectRatio: 1 }}
          className="opacity-0 transition-opacity duration-1000"
          onPointerDown={(e) => {
            pointerInteracting.current = e.clientX - pointerInteractionMovement.current
            if (canvasRef.current) canvasRef.current.style.cursor = "grabbing"
            setIsAutoRotating(false)
            isAutoRotatingRef.current = false
          }}
          onPointerUp={() => {
            pointerInteracting.current = null
            if (canvasRef.current) canvasRef.current.style.cursor = "grab"
          }}
          onPointerOut={() => {
            pointerInteracting.current = null
            if (canvasRef.current) canvasRef.current.style.cursor = "grab"
          }}
          onMouseMove={(e) => {
            if (pointerInteracting.current !== null) {
              const delta = e.clientX - pointerInteracting.current
              pointerInteractionMovement.current = delta
              rRef.current = delta / 200
            }
          }}
          onTouchMove={(e) => {
            if (pointerInteracting.current !== null && e.touches[0]) {
              const delta = e.touches[0].clientX - pointerInteracting.current
              pointerInteractionMovement.current = delta
              rRef.current = delta / 100
            }
          }}
        />
      </div>

      <div className="absolute bottom-8 z-10 w-full px-4">
        <motion.div
          initial={{ y: 30, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          transition={{ type: "spring", damping: 22, stiffness: 110, delay: 0.1 }}
          className="mx-auto flex max-w-fit items-center justify-center gap-1 rounded-full border border-foreground/5 bg-background/50 p-1 backdrop-blur-md"
        >
          {Object.entries(regions).map(([name, coords]) => (
            <button
              key={name}
              onClick={() => handleRegionChange(name, coords)}
              className={cn(
                "group relative flex items-center gap-1.5 rounded-full px-3 sm:px-4 py-1.5 text-[11px] font-medium transition-all duration-300",
                activeRegion === name
                  ? "bg-foreground text-background"
                  : "text-muted-foreground hover:text-foreground"
              )}
            >
              <MapPin
                className={cn(
                  "h-3 w-3 transition-colors",
                  activeRegion === name
                    ? "text-background/80"
                    : "text-muted-foreground group-hover:text-foreground"
                )}
              />
              {name}
            </button>
          ))}

          <div className="mx-1 h-4 w-px bg-foreground/10" />

          <button
            onClick={() => {
              const newVal = !isAutoRotating
              setIsAutoRotating(newVal)
              isAutoRotatingRef.current = newVal
              if (newVal) {
                rRef.current = 0
                pointerInteractionMovement.current = 0
              }
            }}
            className={cn(
              "group relative flex items-center justify-center rounded-full p-1.5 transition-all duration-300",
              isAutoRotating
                ? "text-foreground"
                : "text-muted-foreground hover:text-foreground"
            )}
            title="Auto Rotate"
          >
            <RotateCw
              className={cn("h-3.5 w-3.5", isAutoRotating && "animate-spin")}
              style={{ animationDuration: "6s" }}
            />
          </button>
        </motion.div>
      </div>
    </div>
  )
}

Dependencies

  • cobe: latest
  • framer-motion: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
markersArray<{ location: [number, number]; size: number }>Built-in demo markersMarkers to render on the globe.
autoRotatebooleantrueEnable gentle auto-rotation when idle.
classNamestringundefinedAdditional CSS classes.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.