Velocity UI
Loading…
Menu

Interactive 3D Globe

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

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 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, Activity, Wifi, 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 = {
    "Americas": [40, -100],
    "Europe": [50, 10],
    "Asia": [30, 100],
    "Africa": [10, 20],
    "India": [20, 77]
  }

  const handleRegionChange = (name: string, coords: number[]) => {
    setActiveRegion(name)
    setIsAutoRotating(false)
    isAutoRotatingRef.current = false
    const target = coords[1] * Math.PI / 180
    targetPhiRef.current = target
    // Reset manual rotation offset so we go exactly to the 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 }, // San Francisco
        { location: [40.7128, -74.006], size: 0.05 }, // New York
        { location: [51.5074, -0.1278], size: 0.05 }, // London
        { location: [35.6762, 139.6503], size: 0.05 }, // Tokyo
        { location: [28.6139, 77.2090], size: 0.05 }, // New Delhi
        { location: [-33.8688, 151.2093], size: 0.05 }, // Sydney
        { location: [30.0444, 31.2357], size: 0.05 }, // Cairo
        { location: [55.7558, 37.6173], size: 0.05 }, // Moscow
        { location: [-23.5505, -46.6333], size: 0.05 }, // Sao Paulo
      ],
      onRender: (state) => {
        // This runs on every animation frame
        if (!pointerInteracting.current) {
            // Not dragging
            if (isAutoRotatingRef.current) {
                // Auto rotate
                currentPhi += 0.003
                targetPhiRef.current = currentPhi
            } else {
                // Smoothly go to target region
                const dist = targetPhiRef.current - currentPhi
                currentPhi += dist * 0.08
            }
        } else {
            // When dragging, we pause updating currentPhi from auto-rotation/target
            // The visual rotation is handled by rRef
            // Optionally update targetPhiRef to match current visual state so when we release it doesn't snap
            // But here we keep it simple: currentPhi stays put, rRef adds offset.
        }
        
        state.phi = currentPhi + rRef.current
        state.width = width * 2
        state.height = width * 2
      }
    })

    setTimeout(() => (canvasRef.current!.style.opacity = '1'))
    return () => globe.destroy()
  }, [])

  return (
    <div className="relative flex h-[700px] w-full flex-col items-center justify-center overflow-hidden rounded-3xl bg-background font-sans">
        {/* Cinematic Background */}
        <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-primary/10 via-foreground/5 to-transparent" />
        <div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-10 mix-blend-overlay" />
        
        {/* Grid Overlay */}
        <div className="absolute inset-0 bg-[linear-gradient(hsl(var(--foreground)/0.05)_1px,transparent_1px),linear-gradient(90deg,hsl(var(--foreground)/0.05)_1px,transparent_1px)] bg-[size:100px_100px] [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_70%)] pointer-events-none" />

        {/* HUD UI Layer */}
        <div className="absolute top-8 left-8 z-10 hidden md:block">
            <div className="flex flex-col gap-2">
                <div className="flex items-center gap-2 text-blue-400">
                    <Activity className="h-4 w-4 animate-pulse" />
                    <span className="text-xs font-mono uppercase tracking-widest">System Status: Nominal</span>
                </div>
                <div className="flex items-center gap-2 text-emerald-400">
                    <Wifi className="h-4 w-4" />
                    <span className="text-xs font-mono uppercase tracking-widest">Network: 10.5 Gbps</span>
                </div>
            </div>
        </div>

        {/* Globe Container */}
        <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
                  canvasRef.current!.style.cursor = 'grabbing'
                  // Pause auto-rotation on interaction
                  setIsAutoRotating(false)
                  isAutoRotatingRef.current = false
                }}
                onPointerUp={() => {
                  pointerInteracting.current = null
                  canvasRef.current!.style.cursor = 'grab'
                }}
                onPointerOut={() => {
                  pointerInteracting.current = null
                  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>

        {/* Region Controls */}
        <div className="absolute bottom-8 z-10 w-full px-4">
            <motion.div 
                initial={{ y: 50, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                transition={{ type: "spring", damping: 20, stiffness: 100 }}
                className="mx-auto flex max-w-fit items-center justify-center gap-2 rounded-full border border-border bg-card/80 p-2 backdrop-blur-xl shadow-2xl"
            >
                {Object.entries(regions).map(([name, coords]) => (
                    <button
                        key={name}
                        onClick={() => handleRegionChange(name, coords)}
                        className={cn(
                            "group relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-300",
                            activeRegion === name 
                                ? "bg-foreground text-background shadow-lg shadow-foreground/10 scale-105" 
                                : "text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
                        )}
                    >
                        <MapPin className={cn("h-3 w-3 transition-colors", activeRegion === name ? "text-blue-500" : "text-neutral-500 group-hover:text-white")} />
                        {name}
                        {activeRegion === name && (
                             <span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-blue-500 animate-ping" />
                        )}
                    </button>
                ))}
                
                <div className="mx-2 h-6 w-px bg-white/10" />
                
                <button
                    onClick={() => {
                        const newVal = !isAutoRotating
                        setIsAutoRotating(newVal)
                        isAutoRotatingRef.current = newVal
                        // Reset manual drag if we enable auto-rotate
                        if (newVal) {
                             rRef.current = 0
                             pointerInteractionMovement.current = 0
                        }
                    }}
                    className={cn(
                        "group relative flex items-center justify-center rounded-full p-2 transition-all duration-300",
                        isAutoRotating 
                            ? "bg-blue-500/20 text-blue-400" 
                            : "text-neutral-400 hover:bg-white/10 hover:text-white"
                    )}
                    title="Auto Rotate"
                >
                    <RotateCw className={cn("h-4 w-4", isAutoRotating && "animate-spin-slow")} />
                </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.