Globe 3D

Standalone SVG pseudo-3D globe — orthographic projection with auto-rotation, drag interaction, lat/lng markers, and great-circle arcs. Install Globe 3D from the VLLNT UI registry with the shadcn CLI.

Report a bug

Standalone SVG pseudo-3D globe — orthographic projection with auto-rotation, drag interaction, lat/lng markers, and great-circle arcs. Part of the data-display family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

Add Globe 3D to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/globe-3d.json

Source

"use client"; import { Children, type ComponentPropsWithoutRef, createContext, isValidElement, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode, use, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { cn } from "../../lib/utils"; const VIEWBOX = 400; const SPHERE_RADIUS = 180; const CENTER = VIEWBOX / 2; /** * Geographic coordinate as `{ lat, lng }`. * * @public */ export type GlobeCoord = { lat: number; lng: number }; /** * Color theme for markers and arcs. * * @public */ export type GlobeColor = | "amber" | "blue" | "cyan" | "emerald" | "purple" | "red" | "rose"; const PALETTE: Record<GlobeColor, { fill: string; stroke: string }> = { amber: { fill: "fill-amber-500", stroke: "stroke-amber-500" }, blue: { fill: "fill-blue-500", stroke: "stroke-blue-500" }, cyan: { fill: "fill-cyan-500", stroke: "stroke-cyan-500" }, emerald: { fill: "fill-emerald-500", stroke: "stroke-emerald-500" }, purple: { fill: "fill-purple-500", stroke: "stroke-purple-500" }, red: { fill: "fill-red-500", stroke: "stroke-red-500" }, rose: { fill: "fill-rose-500", stroke: "stroke-rose-500" }, }; /** * Localizable strings. * * @public */ export type Globe3DLabels = { /** Aria-label for the globe region. Defaults to `"Globe"`. */ region?: string; }; const DEFAULT_LABELS = { region: "Globe", } as const satisfies Required<Globe3DLabels>; type Vec3 = { x: number; y: number; z: number }; function toRadians(degrees: number): number { return (degrees * Math.PI) / 180; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function rotate(vector: Vec3, lngOffset: number, latOffset: number): Vec3 { const yawRad = toRadians(lngOffset); const pitchRad = toRadians(latOffset); const cosY = Math.cos(yawRad); const sinY = Math.sin(yawRad); const xy = vector.x * cosY + vector.z * sinY; const zy = -vector.x * sinY + vector.z * cosY; const cosP = Math.cos(pitchRad); const sinP = Math.sin(pitchRad); const yp = vector.y * cosP - zy * sinP; const zp = vector.y * sinP + zy * cosP; return { x: xy, y: yp, z: zp }; } function latLngToVector({ lat, lng }: GlobeCoord): Vec3 { const phi = toRadians(90 - lat); const theta = toRadians(lng); return { x: Math.sin(phi) * Math.sin(theta), y: Math.cos(phi), z: Math.sin(phi) * Math.cos(theta), }; } function project( coord: GlobeCoord, rotationLng: number, rotationLat: number, ): { visible: boolean; x: number; y: number; z: number } { const baseVector = latLngToVector(coord); const rotated = rotate(baseVector, rotationLng, rotationLat); return { visible: rotated.z >= 0, x: CENTER + rotated.x * SPHERE_RADIUS, y: CENTER - rotated.y * SPHERE_RADIUS, z: rotated.z, }; } type Ctx = { rotationLat: number; rotationLng: number; }; const GlobeContext = createContext<Ctx | null>(null); function useGlobeContext(): Ctx { const ctx = use(GlobeContext); if (!ctx) { throw new Error("Globe3D subcomponent used outside its root."); } return ctx; } /** * Props for {@link GlobeMarker}. * * @public */ export type GlobeMarkerProps = { /** Color theme. Defaults to `"red"`. */ color?: GlobeColor; /** Stable id used for analytics + React keys. */ id?: string; /** Optional label rendered next to the marker. */ label?: ReactNode; /** Latitude. Positive = north. */ lat: number; /** Longitude. Positive = east. */ lng: number; } & Omit<ComponentPropsWithoutRef<"g">, "id">; /** * Point marker pinned to a `[lat, lng]`. Hidden when the point falls on * the far side of the globe. * * @public */ export const GlobeMarker = ({ ref, ...props }: GlobeMarkerProps & { ref?: React.Ref<SVGGElement> }) => { const { color = "red", id, label, lat, lng, ...rest } = props; const { rotationLat, rotationLng } = useGlobeContext(); const projected = project({ lat, lng }, rotationLng, rotationLat); if (!projected.visible) return null; const palette = PALETTE[color]; return ( <g data-marker-id={id} data-marker-visible="true" ref={ref} transform={`translate(${projected.x.toString()}, ${projected.y.toString()})`} {...rest} > <circle className={cn("stroke-background", palette.fill)} r="5" strokeWidth="1.5" /> {label ? ( <text className="select-none fill-foreground text-[10px] font-semibold" dominantBaseline="middle" textAnchor="middle" y="-10" > {label} </text> ) : null} </g> ); }; GlobeMarker.displayName = "GlobeMarker"; /** * Props for {@link GlobeArc}. * * @public */ export type GlobeArcProps = { /** Color theme. Defaults to `"cyan"`. */ color?: GlobeColor; /** Origin coordinate. */ from: GlobeCoord; /** Stable id used for analytics + React keys. */ id?: string; /** Stroke width in viewBox units. Defaults to `2`. */ strokeWidth?: number; /** Destination coordinate. */ to: GlobeCoord; } & Omit<ComponentPropsWithoutRef<"path">, "d" | "id" | "stroke">; function buildArc(arguments_: { from: GlobeCoord; rotationLat: number; rotationLng: number; to: GlobeCoord; }): string { const { from, rotationLat, rotationLng, to } = arguments_; const samples = 36; return Array.from({ length: samples + 1 }) .map((_, index) => { const t = index / samples; const lat = from.lat + (to.lat - from.lat) * t; const lng = from.lng + (to.lng - from.lng) * t; return project({ lat, lng }, rotationLng, rotationLat); }) .reduce<{ path: string; pen: "down" | "up" }>( (state, projected) => { if (!projected.visible) return { path: state.path, pen: "up" }; const head = state.pen === "up" ? "M" : "L"; const separator = state.path.length > 0 ? " " : ""; return { path: `${state.path}${separator}${head}${projected.x.toString()},${projected.y.toString()}`, pen: "down", }; }, { path: "", pen: "up" }, ).path; } /** * Great-circle-style arc between two coordinates. The component clips * any segment on the far side of the globe. * * @public */ export const GlobeArc = ({ ref, ...props }: GlobeArcProps & { ref?: React.Ref<SVGPathElement> }) => { const { color = "cyan", from, id, strokeWidth = 2, to, ...rest } = props; const { rotationLat, rotationLng } = useGlobeContext(); const path = buildArc({ from, rotationLat, rotationLng, to }); if (!path) return null; const palette = PALETTE[color]; return ( <path className={cn("fill-none", palette.stroke)} d={path} data-arc-id={id} ref={ref} strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} {...rest} /> ); }; GlobeArc.displayName = "GlobeArc"; type GraticuleProps = { rotationLat: number; rotationLng: number; }; function buildLine(arguments_: { points: GlobeCoord[]; rotationLat: number; rotationLng: number; }): string { const { points, rotationLat, rotationLng } = arguments_; return points.reduce<{ path: string; pen: "down" | "up" }>( (state, coord) => { const projected = project(coord, rotationLng, rotationLat); if (!projected.visible) { return { path: state.path, pen: "up" }; } const head = state.pen === "up" ? "M" : "L"; const separator = state.path.length > 0 ? " " : ""; return { path: `${state.path}${separator}${head}${projected.x.toString()},${projected.y.toString()}`, pen: "down", }; }, { path: "", pen: "up" }, ).path; } function range(start: number, end: number, step: number): number[] { const length = Math.floor((end - start) / step) + 1; return Array.from({ length }).map((_, index) => start + index * step); } function Graticule({ rotationLat, rotationLng }: GraticuleProps): ReactNode { const parallels = range(-60, 60, 30).map<GlobeCoord[]>((lat) => range(-180, 180, 5).map((lng) => ({ lat, lng })), ); const meridians = range(-150, 180, 30).map<GlobeCoord[]>((lng) => range(-85, 85, 5).map((lat) => ({ lat, lng })), ); const lines = [...parallels, ...meridians].reduce<string[]>( (paths, points) => { const path = buildLine({ points, rotationLat, rotationLng }); if (path.length > 0) { paths.push(path); } return paths; }, [], ); return ( <g className="fill-none stroke-foreground/20" data-globe-graticule strokeWidth={0.5} > {lines.map((path) => ( <path d={path} key={path} /> ))} </g> ); } type SphereProps = { children: ReactNode; rotationLat: number; rotationLng: number; }; function Sphere({ children, rotationLat, rotationLng, }: SphereProps): ReactNode { return ( <svg aria-hidden="true" className="block h-full w-full touch-none" data-rotation-lat={rotationLat} data-rotation-lng={rotationLng} preserveAspectRatio="xMidYMid meet" viewBox={`0 0 ${VIEWBOX.toString()} ${VIEWBOX.toString()}`} > <defs> <radialGradient cx="50%" cy="40%" id="globe-shade" r="60%"> <stop offset="0%" stopColor="rgba(99, 102, 241, 0.4)" /> <stop offset="100%" stopColor="rgba(15, 23, 42, 0.85)" /> </radialGradient> </defs> <circle className="fill-[url(#globe-shade)] stroke-foreground/30" cx={CENTER} cy={CENTER} data-globe-sphere r={SPHERE_RADIUS} strokeWidth={1} /> <Graticule rotationLat={rotationLat} rotationLng={rotationLng} /> {children} </svg> ); } /** * Props for {@link Globe3D}. * * @public */ export type Globe3DProps = { /** When `true`, the globe rotates on its own. Defaults to `true`. */ autoRotate?: boolean; /** Initial view position. */ initialPosition?: GlobeCoord; /** Localizable strings. */ labels?: Globe3DLabels; /** Auto-rotation rate in degrees per second. Defaults to `8`. */ rotationSpeed?: number; } & ComponentPropsWithoutRef<"section">; function useAutoRotation(arguments_: { autoRotate: boolean; isDragging: boolean; rotationSpeed: number; setRotationLng: (next: (current: number) => number) => void; }): void { const { autoRotate, isDragging, rotationSpeed, setRotationLng } = arguments_; useEffect(() => { if (!autoRotate || isDragging) return; if (typeof window === "undefined") return; let frame = 0; let last: null | number = null; const step = (timestamp: number): void => { if (last !== null) { const delta = (timestamp - last) / 1000; setRotationLng((current) => current + delta * rotationSpeed); } last = timestamp; frame = window.requestAnimationFrame(step); }; frame = window.requestAnimationFrame(step); return () => { window.cancelAnimationFrame(frame); }; }, [autoRotate, isDragging, rotationSpeed, setRotationLng]); } type DragState = null | { originLat: number; originLng: number; originX: number; originY: number; }; type DragHandlers = { onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void; }; function useDragRotation(arguments_: { rotationLat: number; rotationLng: number; setIsDragging: (next: boolean) => void; setRotationLat: (next: number) => void; setRotationLng: (next: number) => void; }): DragHandlers { const { rotationLat, rotationLng, setIsDragging, setRotationLat, setRotationLng, } = arguments_; const dragRef = useRef<DragState>(null); const onPointerDown = useCallback( (event: ReactPointerEvent<HTMLDivElement>): void => { dragRef.current = { originLat: rotationLat, originLng: rotationLng, originX: event.clientX, originY: event.clientY, }; event.currentTarget.setPointerCapture(event.pointerId); setIsDragging(true); }, [rotationLat, rotationLng, setIsDragging], ); const onPointerMove = useCallback( (event: ReactPointerEvent<HTMLDivElement>): void => { const drag = dragRef.current; if (!drag) return; const dx = event.clientX - drag.originX; const dy = event.clientY - drag.originY; setRotationLng(drag.originLng + dx * 0.4); setRotationLat(clamp(drag.originLat - dy * 0.4, -85, 85)); }, [setRotationLat, setRotationLng], ); const onPointerEnd = useCallback( (event: ReactPointerEvent<HTMLDivElement>): void => { const target = event.currentTarget; if (target.hasPointerCapture(event.pointerId)) { target.releasePointerCapture(event.pointerId); } dragRef.current = null; setIsDragging(false); }, [setIsDragging], ); return { onPointerCancel: onPointerEnd, onPointerDown, onPointerMove, onPointerUp: onPointerEnd, }; } function useGlobeRotation(initialPosition: GlobeCoord): { isDragging: boolean; rotationLat: number; rotationLng: number; setIsDragging: (next: boolean) => void; setRotationLat: (next: number) => void; setRotationLng: (next: ((current: number) => number) | number) => void; } { const [rotationLng, setRotationLng] = useState<number>(-initialPosition.lng); const [rotationLat, setRotationLat] = useState<number>(initialPosition.lat); const [isDragging, setIsDragging] = useState(false); return { isDragging, rotationLat, rotationLng, setIsDragging, setRotationLat, setRotationLng, }; } type DataSummaryProps = { children: ReactNode; titleId: string; }; function DataSummary({ children, titleId }: DataSummaryProps): ReactNode { const markers: { color: string; label: ReactNode; lat: number; lng: number; }[] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) return; const element = child as ReactElement<GlobeMarkerProps>; if ( typeof element.props.lat === "number" && typeof element.props.lng === "number" ) { markers.push({ color: element.props.color ?? "red", label: element.props.label ?? null, lat: element.props.lat, lng: element.props.lng, }); } }); return ( <div aria-labelledby={titleId} className="sr-only" role="region"> <h3 id={titleId}>Globe data summary</h3> <p>{markers.length.toString()} marker(s) plotted on the globe.</p> <ul> {markers.map((marker, index) => ( <li key={`${index.toString()}-${marker.lat.toString()}-${marker.lng.toString()}`} > {`${marker.lat.toString()}, ${marker.lng.toString()}: ${marker.label ? String(marker.label) : marker.color}`} </li> ))} </ul> </div> ); } /** * Standalone SVG pseudo-3D globe. Renders a unit sphere with * orthographic projection, a graticule (lat/lng lines), and any * {@link GlobeMarker} / {@link GlobeArc} children. Auto-rotates around * the Y axis; drag to rotate manually (auto-rotation pauses while * dragging). No external 3D library — pure SVG + React state. * * @example * ```tsx * <Globe3D autoRotate> * <GlobeMarker lat={48.85} lng={2.35} label="Paris" color="blue" /> * <GlobeMarker lat={40.71} lng={-74} label="New York" color="red" /> * <GlobeArc * from={{ lat: 48.85, lng: 2.35 }} * to={{ lat: 40.71, lng: -74 }} * color="cyan" * /> * </Globe3D> * ``` * * @public */ export const Globe3D = ({ ref, ...props }: Globe3DProps & { ref?: React.Ref<HTMLElement> }) => { const { autoRotate = true, children, className, initialPosition = { lat: 20, lng: 0 }, labels, rotationSpeed = 8, ...rest } = props; const titleId = useId(); const resolvedLabels = useMemo( () => ({ ...DEFAULT_LABELS, ...labels }), [labels], ); const { isDragging, rotationLat, rotationLng, setIsDragging, setRotationLat, setRotationLng, } = useGlobeRotation(initialPosition); useAutoRotation({ autoRotate, isDragging, rotationSpeed, setRotationLng }); const handlers = useDragRotation({ rotationLat, rotationLng, setIsDragging, setRotationLat, setRotationLng: (next) => { setRotationLng(next); }, }); const ctx = useMemo<Ctx>( () => ({ rotationLat, rotationLng }), [rotationLat, rotationLng], ); return ( <GlobeContext.Provider value={ctx}> <section aria-labelledby={titleId} className={cn( "relative aspect-square w-full max-w-md overflow-hidden rounded-2xl border bg-background text-foreground", className, )} ref={ref} {...rest} > <span className="sr-only" id={titleId}> {resolvedLabels.region} </span> <div className="block h-full w-full cursor-grab active:cursor-grabbing" data-globe-stage {...handlers} > <Sphere rotationLat={rotationLat} rotationLng={rotationLng}> {children} </Sphere> </div> <DataSummary titleId={titleId}>{children}</DataSummary> </section> </GlobeContext.Provider> ); }; Globe3D.displayName = "Globe3D";

Stories

Explore every variant and state in the interactive Storybook:

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Dependencies

  • @vllnt/ui@^0.3.0