An interactive WebGL dot globe (cobe) with markers, drag-to-spin momentum and arrow-key control. Colors come from theme tokens and repaint on theme change; starts lazily and pauses off-screen.
Deployed to 35 regions
Every push runs close to your users, on every continent.
- regions
- 35
- median latency
- 38 ms
- uptime
- 99.99%
import { Globe, type GlobeMarker } from "@/components/ballmac/globe"
const regions: GlobeMarker[] = [
{ location: [37.77, -122.42], size: 0.06 },
{ location: [40.71, -74.01], size: 0.07 },
{ location: [45.5, -73.57], size: 0.04 },
{ location: [-23.55, -46.63], size: 0.06 },
{ location: [51.51, -0.13], size: 0.07 },
{ location: [50.11, 8.68], size: 0.06 },
{ location: [59.33, 18.07], size: 0.04 },
{ location: [25.2, 55.27], size: 0.05 },
{ location: [19.08, 72.88], size: 0.06 },
{ location: [1.35, 103.82], size: 0.06 },
{ location: [35.68, 139.69], size: 0.07 },
{ location: [37.57, 126.98], size: 0.05 },
{ location: [-33.87, 151.21], size: 0.06 },
{ location: [-33.92, 18.42], size: 0.04 },
]
const stats = [
{ value: "35", label: "regions" },
{ value: "38 ms", label: "median latency" },
{ value: "99.99%", label: "uptime" },
]
export default function GlobeDemo() {
return (
<div className="relative isolate h-[380px] w-full max-w-xl overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
<div className="relative z-10 p-6">
<span className="inline-flex items-center gap-1.5 rounded-full border bg-background/70 px-2.5 py-0.5 text-xs font-medium backdrop-blur">
<span className="size-1.5 rounded-full bg-chart-2" aria-hidden="true" />
Live
</span>
<h3 className="mt-3 text-2xl font-semibold tracking-tight">Deployed to 35 regions</h3>
<p className="mt-1 max-w-[16rem] text-sm text-muted-foreground">
Every push runs close to your users, on every continent.
</p>
<dl className="mt-5 flex flex-col gap-3 sm:gap-3.5">
{stats.map((s) => (
<div key={s.label} className="flex items-baseline gap-2">
<dt className="sr-only">{s.label}</dt>
<dd className="text-lg font-semibold tabular-nums tracking-tight">{s.value}</dd>
<span aria-hidden="true" className="text-xs text-muted-foreground">
{s.label}
</span>
</div>
))}
</dl>
</div>
<Globe
markers={regions}
label="Globe with 14 highlighted deployment regions"
className="absolute top-40 -end-36 w-[440px] max-w-none sm:top-10 sm:-end-28"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-card to-transparent"
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/globeInstall the dependencies.
$ pnpm add cobe@^0.6Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/color @ballmac/i18nCopy the source into your project.
components/ballmac/globe.tsx// Ballmac UI: Globe. https://ui.ballmac.com/components/globe "use client" import * as React from "react" import createGlobe from "cobe" import { cn } from "@/lib/utils" import { cssColorToRgba, observeTheme } from "@/lib/ballmac/color" import { useMessages } from "@/lib/ballmac/i18n" type GlobeMarker = { /** [latitude, longitude] in degrees. */ location: [number, number] /** Dot size, roughly 0.02–0.1. */ size: number } type GlobeProps = Omit<React.ComponentProps<"div">, "children"> & { /** Points drawn on the globe, such as regions or offices. */ markers?: GlobeMarker[] /** Color of the land dots. A CSS variable name ("--muted-foreground") or any CSS color. */ baseColor?: string /** Color of the markers. */ markerColor?: string /** Color of the atmosphere glow around the edge. */ glowColor?: string /** Radians per frame of automatic rotation. Off under reduced motion. */ speed?: number /** Starting longitude rotation in radians. */ phi?: number /** Tilt toward the viewer in radians. */ theta?: number /** Brightness of land dots (cobe mapBrightness). */ mapBrightness?: number /** Number of land dots sampled on the sphere; higher is finer and slower. */ mapSamples?: number /** Let people rotate the globe by dragging or with the arrow keys. */ interactive?: boolean /** Accessible description of what the globe shows. */ label?: string } const TAU = Math.PI * 2 /** True when the page background is dark, so the globe switches to its dark shading. */ function isDarkSurface(element: Element) { const [r, g, b] = cssColorToRgba(element, "--background") return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 } function Globe({ markers = [], baseColor, markerColor = "--chart-1", glowColor, speed = 0.0032, phi = 0, theta = 0.28, mapBrightness, mapSamples = 16000, interactive = true, label, className, ...props }: GlobeProps) { const msg = useMessages() label ??= msg("globe.label", "Rotating globe") const canvasRef = React.useRef<HTMLCanvasElement>(null) // Rotation lives in refs so pointer input never re-renders the component. const motion = React.useRef({ phi, theta, velocity: 0, thetaVelocity: 0, dragging: false, lastX: 0, lastY: 0 }) const [ready, setReady] = React.useState(false) const [themeKey, setThemeKey] = React.useState(0) const markersKey = JSON.stringify(markers) React.useEffect(() => observeTheme(() => setThemeKey((k) => k + 1)), []) React.useEffect(() => { const canvas = canvasRef.current if (!canvas) return const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches const dark = isDarkSurface(canvas) const rgb = (color: string) => cssColorToRgba(canvas, color).slice(0, 3) as [number, number, number] const base = rgb(baseColor ?? (dark ? "--muted-foreground" : "--background")) // Default glow: a soft tint of --chart-1, dimmed on dark surfaces and lifted toward the page on light ones. const tint = rgb("--chart-1") const page = rgb("--background") const glow = glowColor ? rgb(glowColor) : (tint.map((c, i) => (dark ? c * 0.6 : page[i]! * 0.75 + c * 0.25)) as [number, number, number]) const state = motion.current const dpr = Math.min(window.devicePixelRatio || 1, 2) let width = canvas.offsetWidth let globe: ReturnType<typeof createGlobe> | null = null let started = false let visible = false let firstFrame = true const start = () => { if (started) return started = true width = canvas.offsetWidth try { globe = createGlobe(canvas, { devicePixelRatio: dpr, width: width * dpr, height: width * dpr, phi: state.phi, theta: state.theta, dark: dark ? 1 : 0, diffuse: dark ? 1.4 : 1.1, mapSamples, mapBrightness: mapBrightness ?? (dark ? 5 : 1.6), mapBaseBrightness: dark ? 0.02 : 0, baseColor: dark ? base.map((c) => c * 0.55) as [number, number, number] : base, // cobe renders markers darker on light globes; lift them toward white to keep the token hue readable. markerColor: dark ? rgb(markerColor) : (rgb(markerColor).map((c) => c * 0.85 + 0.15) as [number, number, number]), glowColor: glow, opacity: dark ? 0.9 : 1, markers: JSON.parse(markersKey) as GlobeMarker[], onRender: (frame) => { if (!state.dragging) { if (!reduceMotion) state.phi += speed state.phi += state.velocity state.velocity *= 0.94 // Tilt springs back toward its resting angle. state.thetaVelocity += (theta - state.theta) * 0.02 state.thetaVelocity *= 0.82 state.theta += state.thetaVelocity } state.phi %= TAU frame.phi = state.phi frame.theta = state.theta frame.width = width * dpr frame.height = width * dpr if (firstFrame) { firstFrame = false setReady(true) } }, }) globe.toggle(visible && !document.hidden) } catch { // No WebGL (old browsers, tests): leave the empty, labelled canvas. } } const sync = () => globe?.toggle(visible && !document.hidden) const io = new IntersectionObserver( ([entry]) => { visible = entry?.isIntersecting ?? true if (visible) start() sync() }, { rootMargin: "120px" } ) io.observe(canvas) const ro = new ResizeObserver(() => { width = canvas.offsetWidth }) ro.observe(canvas) document.addEventListener("visibilitychange", sync) return () => { io.disconnect() ro.disconnect() document.removeEventListener("visibilitychange", sync) globe?.destroy() } }, [themeKey, markersKey, baseColor, markerColor, glowColor, speed, theta, mapBrightness, mapSamples]) const onPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => { if (!interactive) return const state = motion.current state.dragging = true state.velocity = 0 state.lastX = event.clientX state.lastY = event.clientY event.currentTarget.setPointerCapture(event.pointerId) } const onPointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => { const state = motion.current if (!state.dragging) return const size = event.currentTarget.offsetWidth || 1 const dx = ((event.clientX - state.lastX) / size) * Math.PI const dy = ((event.clientY - state.lastY) / size) * Math.PI state.lastX = event.clientX state.lastY = event.clientY state.phi += dx // Blend in the latest movement so a flick carries momentum after release. state.velocity = state.velocity * 0.5 + dx * 0.5 state.theta = Math.min(Math.max(state.theta + dy * 0.5, -0.6), 0.9) } const onPointerUp = (event: React.PointerEvent<HTMLCanvasElement>) => { motion.current.dragging = false if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } } const onKeyDown = (event: React.KeyboardEvent<HTMLCanvasElement>) => { if (!interactive) return const state = motion.current if (event.key === "ArrowLeft") state.velocity -= 0.04 else if (event.key === "ArrowRight") state.velocity += 0.04 else if (event.key === "ArrowUp") state.thetaVelocity -= 0.04 else if (event.key === "ArrowDown") state.thetaVelocity += 0.04 else return event.preventDefault() } return ( <div data-slot="globe" className={cn("relative mx-auto aspect-square w-full max-w-[600px]", className)} {...props}> <canvas ref={canvasRef} data-slot="globe-canvas" role="img" aria-label={interactive ? `${label}. Drag or use the arrow keys to rotate.` : label} tabIndex={interactive ? 0 : undefined} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onKeyDown={onKeyDown} className={cn( "size-full rounded-full opacity-0 outline-none transition-opacity duration-700 ease-out [contain:layout_paint_size] focus-visible:ring-[3px] focus-visible:ring-ring/50", ready && "opacity-100", interactive && "cursor-grab touch-pan-y active:cursor-grabbing" )} /> </div> ) } export { Globe, type GlobeProps, type GlobeMarker }Update the import paths to match your project setup.
Usage
import { Globe } from "@/components/ballmac/globe"The full example is in the Code tab above.
Examples
Hero horizon
Fast everywhere your users are.
import { Globe } from "@/components/ballmac/globe"
export default function GlobeHero() {
return (
<div className="relative flex h-[380px] w-full max-w-2xl flex-col items-center overflow-hidden rounded-xl border bg-background px-6 pt-12 text-center">
<span className="font-mono text-xs tracking-widest text-muted-foreground uppercase">Edge network</span>
<h2 className="mt-3 max-w-md text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Fast everywhere your users are.
</h2>
<Globe
speed={0.002}
theta={0.15}
markers={[
{ location: [51.51, -0.13], size: 0.06 },
{ location: [40.71, -74.01], size: 0.06 },
{ location: [35.68, 139.69], size: 0.06 },
]}
label="Globe showing three edge locations"
className="absolute top-44 left-1/2 w-[640px] max-w-none -translate-x-1/2"
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
markersPoints drawn on the globe, such as regions or offices. | GlobeMarker[] | [] |
baseColorColor of the land dots. A CSS variable name ("--muted-foreground") or any CSS color. | string | — |
markerColorColor of the markers. | string | "--chart-1" |
glowColorColor of the atmosphere glow around the edge. | string | — |
speedRadians per frame of automatic rotation. Off under reduced motion. | number | 0.0032 |
phiStarting longitude rotation in radians. | number | 0 |
thetaTilt toward the viewer in radians. | number | 0.28 |
mapBrightnessBrightness of land dots (cobe mapBrightness). | number | — |
mapSamplesNumber of land dots sampled on the sphere; higher is finer and slower. | number | 16000 |
interactiveLet people rotate the globe by dragging or with the arrow keys. | boolean | true |
labelAccessible description of what the globe shows. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow Left / Arrow Right | Spins the globe |
| Arrow Up / Arrow Down | Tilts the globe; it settles back |
Use with AI
Render <Globe markers={[{ location: [lat, lng], size: 0.06 }]} /> in a sized container (it is a square, w-full, max 600px). The canvas has role=img and an aria-label; pass label to describe what it shows. It starts WebGL only when scrolled near the viewport. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Globe (@ballmac/globe) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing global reach: regions, edge locations, offices, customers by country
- A hero or feature-card visual for infrastructure, networking or travel products
Not for
- Precise geographic data or choropleths (use a real map library)
- Several globes on one page; each is a WebGL context
Registry JSON: https://ui.ballmac.com/r/globe.json
Credits
Free to use in personal and commercial projects.
- npm
- cobe@^0.6
Pairs well with
Number Ticker
Counts up to a number when it scrolls into view, with locale-aware formatting for currency, percentages and decimals.
Spotlight Card
A card whose surface and border pick up a soft glow that follows the pointer, driven by CSS variables with no re-render per move. Keyboard focus shows a steady glow.
Badge
A small pill label in four variants with an optional status dot and success, warning and error tones. Style links with badgeVariants().
Aurora Background
Soft, slowly drifting aurora light made of blurred token-colored glows with fine vertical curtains and an optional radial fade. Pure CSS layers, paused off-screen, still under reduced motion.