Dotted Globe
A rotating globe of dots with arcs; drag to spin.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/dotted-globe.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/color.tsexport type Vec3 = [r: number, g: number, b: number]; /** * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels. * Anything else falls back to black, so a typo never crashes a shader. */ export function hexToRgb(hex: string): Vec3 { let value = hex.trim().replace(/^#/, ""); if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join(""); if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0]; const n = Number.parseInt(value.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function hexToVec3(hex: string): Vec3 { const [r, g, b] = hexToRgb(hex); return [r / 255, g / 255, b / 255]; } /** * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result * instead of calling this every frame. Browser only; unreadable values fall back to black. */ export function cssColorToRgb(color: string, element: Element): Vec3 { const value = color.trim(); if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value); // Children of a canvas still get computed styles, but a regular parent is the safer host. const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element; const probe = document.createElement("span"); probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden"; probe.style.color = value; host.appendChild(probe); const computed = getComputedStyle(probe).color; probe.remove(); // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes. const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!ctx) return [0, 0, 0]; ctx.fillStyle = "#000"; ctx.fillStyle = computed; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return [r, g, b]; } /** * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme. * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe. */ export function observeTheme(onChange: () => void): () => void { const observer = new MutationObserver(onChange); const options = { attributes: true, attributeFilter: ["class", "data-theme"] }; observer.observe(document.documentElement, options); if (document.body) observer.observe(document.body, options); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); scheme.addEventListener("change", onChange); return () => { observer.disconnect(); scheme.removeEventListener("change", onChange); }; } /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function cssColorToVec3(color: string, element: Element): Vec3 { const [r, g, b] = cssColorToRgb(color, element); return [r / 255, g / 255, b / 255]; } const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */ export function rgbToOklch([r, g, b]: Vec3): Vec3 { const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255)); const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return [L, Math.hypot(A, B), Math.atan2(B, A)]; } /** OKLCH back to 0–255 sRGB, clamped into gamut. */ export function oklchToRgb([L, C, h]: Vec3): Vec3 { const A = C * Math.cos(h); const B = C * Math.sin(h); const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3; const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3; const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3; const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]; return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3; } /** * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep * their chroma instead of turning muddy the way an RGB mix does. */ export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 { const [L1, C1, h1] = rgbToOklch(a); const [L2, C2, h2] = rgbToOklch(b); // A near-gray end has no meaningful hue: borrow the other end's. const ha = C1 < 0.02 ? h2 : h1; const hb = C2 < 0.02 ? h1 : h2; let dh = hb - ha; if (dh > Math.PI) dh -= Math.PI * 2; if (dh < -Math.PI) dh += Math.PI * 2; return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]); }Copy the source
components/ui/dotted-globe/dotted-globe.tsx"use client"; import { useEffect, useRef, type ComponentProps } from "react"; import { cn } from "@/lib/utils"; import { cssColorToRgb, observeTheme } from "@/lib/color"; export interface GlobeMarker { lat: number; lon: number; /** City or place name, included in the accessible description. */ label?: string; } export interface GlobeArc { from: [lat: number, lon: number]; to: [lat: number, lon: number]; } export interface DottedGlobeProps extends Omit<ComponentProps<"div">, "children"> { /** Highlighted locations. */ markers?: GlobeMarker[]; /** Arcs between locations, drawn as lifted great circles. */ arcs?: GlobeArc[]; /** Land dot color (any CSS color, including a theme variable). @default "var(--foreground)" */ dotColor?: string; /** Marker and arc color (any CSS color, including a theme variable). @default "var(--primary)" */ accentColor?: string; /** Auto-rotation in degrees per second. @default 8 */ speed?: number; /** Drag to rotate. @default true */ draggable?: boolean; /** Accessible label. @default "Globe" */ label?: string; } const RAD = Math.PI / 180; /** Stylized continents from layered sines: a pleasant land mask without shipping map data. */ function isLand(lat: number, lon: number) { const a = Math.sin(lat * 2.6 + 1.1) * Math.cos(lon * 1.7 - 0.4); const b = 0.6 * Math.sin(lon * 3.1 + lat * 1.3 + 2.2); const c = 0.35 * Math.cos(lat * 5.3 - lon * 2.4); return a + b + c > 0.35 && Math.abs(lat) < 72 * RAD; } /** Evenly spread sphere points; the fourth value marks land (1) or ocean (0). */ function fibonacciSphere(count: number) { const points: [number, number, number, number][] = []; const golden = Math.PI * (3 - Math.sqrt(5)); for (let i = 0; i < count; i++) { const y = 1 - (i / (count - 1)) * 2; const r = Math.sqrt(1 - y * y); const theta = golden * i; const x = Math.cos(theta) * r; const z = Math.sin(theta) * r; const lat = Math.asin(y); const lon = Math.atan2(x, z); points.push([x, y, z, isLand(lat, lon) ? 1 : 0]); } return points; } const toVector = (lat: number, lon: number): [number, number, number] => [Math.cos(lat * RAD) * Math.sin(lon * RAD), Math.sin(lat * RAD), Math.cos(lat * RAD) * Math.cos(lon * RAD)]; export function DottedGlobe({ markers = [], arcs = [], dotColor = "var(--foreground)", accentColor = "var(--primary)", speed = 8, draggable = true, label = "Globe", className, ...props }: DottedGlobeProps) { const canvas = useRef<HTMLCanvasElement>(null); const latest = useRef({ markers, arcs, dotColor, accentColor, speed }); useEffect(() => { latest.current = { markers, arcs, dotColor, accentColor, speed }; }); useEffect(() => { const el = canvas.current; const ctx = el?.getContext("2d"); if (!el || !ctx) return; const points = fibonacciSphere(2400); const reduce = window.matchMedia("(prefers-reduced-motion: reduce)"); let yaw = -20 * RAD; let pitch = -0.32; let velocity = 0; let dragging = false; let lastX = 0; let lastY = 0; let raf = 0; let last = 0; let visible = true; let size = 0; let dpr = 1; // Colors may be theme variables, which a canvas can't read: resolve them when they change. const resolved = new Map<string, string>(); const paint = (color: string) => { let value = resolved.get(color); if (!value) { value = `rgb(${cssColorToRgb(color, el).join(",")})`; resolved.set(color, value); } return value; }; const resize = () => { dpr = Math.min(window.devicePixelRatio || 1, 2); size = el.clientWidth; el.width = Math.round(size * dpr); el.height = Math.round(size * dpr); }; const project = ([x, y, z]: readonly number[]) => { const cy = Math.cos(yaw); const sy = Math.sin(yaw); const x1 = x * cy + z * sy; const z1 = -x * sy + z * cy; const cp = Math.cos(pitch); const sp = Math.sin(pitch); const y2 = y * cp - z1 * sp; const z2 = y * sp + z1 * cp; return [x1, y2, z2] as const; }; const draw = (time: number) => { const { markers: m, arcs: a } = latest.current; const dc = paint(latest.current.dotColor); const ac = paint(latest.current.accentColor); const R = size * 0.46; const c = size / 2; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, size, size); ctx.beginPath(); ctx.arc(c, c, R, 0, Math.PI * 2); ctx.strokeStyle = dc; ctx.globalAlpha = 0.08; ctx.stroke(); ctx.fillStyle = dc; // Land dots are darker and larger; ocean dots stay faint so the sphere always reads. for (const p of points) { const [x, y, z] = project(p); const land = p[3] === 1; if (z <= 0) { if (!land) continue; ctx.globalAlpha = 0.05; ctx.fillRect(c + x * R - 0.4, c - y * R - 0.4, 0.8, 0.8); continue; } ctx.globalAlpha = land ? 0.3 + z * 0.6 : 0.06 + z * 0.1; const r = land ? 1.3 + z * 0.6 : 0.9; ctx.fillRect(c + x * R - r / 2, c - y * R - r / 2, r, r); } // Arcs: lifted great circles with a dash flowing along them. ctx.strokeStyle = ac; ctx.lineWidth = 1.5; ctx.lineCap = "round"; for (const arc of a) { const v1 = toVector(...arc.from); const v2 = toVector(...arc.to); const angle = Math.acos(Math.min(1, Math.max(-1, v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2]))); ctx.beginPath(); let started = false; for (let s = 0; s <= 40; s++) { const t = s / 40; const k1 = Math.sin((1 - t) * angle) / Math.sin(angle || 1); const k2 = Math.sin(t * angle) / Math.sin(angle || 1); const lift = 1 + Math.sin(t * Math.PI) * 0.18; const v: [number, number, number] = [(v1[0] * k1 + v2[0] * k2) * lift, (v1[1] * k1 + v2[1] * k2) * lift, (v1[2] * k1 + v2[2] * k2) * lift]; const [x, y, z] = project(v); if (z < -0.05) { started = false; continue; } if (started) ctx.lineTo(c + x * R, c - y * R); else ctx.moveTo(c + x * R, c - y * R); started = true; } ctx.globalAlpha = 0.85; ctx.setLineDash([6, 8]); ctx.lineDashOffset = -time * 0.02; ctx.stroke(); ctx.setLineDash([]); } // Markers with a soft pulse. for (const marker of m) { const [x, y, z] = project(toVector(marker.lat, marker.lon)); if (z <= 0) continue; const px = c + x * R; const py = c - y * R; const pulse = (time / 1600) % 1; ctx.globalAlpha = (1 - pulse) * 0.5; ctx.beginPath(); ctx.arc(px, py, 3 + pulse * 10, 0, Math.PI * 2); ctx.fillStyle = ac; ctx.fill(); ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(px, py, 3, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; }; const frame = (now: number) => { raf = 0; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; if (!dragging) { velocity *= 0.94; yaw += (latest.current.speed * RAD + velocity) * dt; } draw(now); if (visible && !reduce.matches && document.visibilityState === "visible") raf = requestAnimationFrame(frame); else last = 0; }; const run = () => { if (!raf) raf = requestAnimationFrame(frame); }; resize(); draw(0); run(); const resizeObserver = new ResizeObserver(() => { resize(); draw(performance.now()); }); resizeObserver.observe(el); const intersection = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible) run(); }); intersection.observe(el); const onVisibility = () => document.visibilityState === "visible" && run(); document.addEventListener("visibilitychange", onVisibility); // A theme switch changes what the variables resolve to. const unobserveTheme = observeTheme(() => { resolved.clear(); draw(performance.now()); }); const onDown = (event: PointerEvent) => { if (!draggable) return; dragging = true; lastX = event.clientX; lastY = event.clientY; el.setPointerCapture(event.pointerId); }; const onMove = (event: PointerEvent) => { if (!dragging) return; const dx = event.clientX - lastX; const dy = event.clientY - lastY; lastX = event.clientX; lastY = event.clientY; yaw += dx * 0.008; pitch = Math.max(-1.1, Math.min(1.1, pitch + dy * 0.006)); velocity = dx * 0.4; draw(performance.now()); run(); }; const onUp = () => { dragging = false; }; el.addEventListener("pointerdown", onDown); el.addEventListener("pointermove", onMove); el.addEventListener("pointerup", onUp); el.addEventListener("pointercancel", onUp); return () => { cancelAnimationFrame(raf); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); unobserveTheme(); el.removeEventListener("pointerdown", onDown); el.removeEventListener("pointermove", onMove); el.removeEventListener("pointerup", onUp); el.removeEventListener("pointercancel", onUp); }; }, [draggable]); const places = markers.map((m) => m.label).filter(Boolean); return ( <div className={cn("relative aspect-square w-full max-w-md min-w-64", className)} {...props}> <canvas ref={canvas} role="img" aria-label={places.length ? `${label} showing ${places.join(", ")}` : label} className={cn("size-full touch-none", draggable && "cursor-grab active:cursor-grabbing")} /> </div> ); }
Usage
import { DottedGlobe } from "@/components/ui/dotted-globe/dotted-globe";
<DottedGlobe
markers={[{ lat: -6.2, lon: 106.8, label: "Jakarta" }]}
arcs={[{ from: [-6.2, 106.8], to: [51.5, -0.1] }]}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
markers | GlobeMarker[] | – | Highlighted locations. |
arcs | GlobeArc[] | – | Arcs between locations, drawn as lifted great circles. |
dotColor | string | "var(--foreground)" | Land dot color (any CSS color, including a theme variable). |
accentColor | string | "var(--primary)" | Marker and arc color (any CSS color, including a theme variable). |
speed | number | 8 | Auto-rotation in degrees per second. |
draggable | boolean | true | Drag to rotate. |
label | string | "Globe" | Accessible label. |
Also accepts every native <div> attribute, including ref and className.
- About 2,600 Fibonacci-sphere points are filtered by a layered-sine land mask, so you get stylized continents without shipping map data. Swap isLand for a real mask if you need geography.
- Canvas 2D at up to 2× pixel ratio. It pauses offscreen and in hidden tabs, and drag uses pointer capture with a little inertia.
Accessibility
- The canvas is an image whose label names every marked place, so the locations aren't visual-only.
- Dragging is optional decoration; nothing needs the globe to be rotated.
- With reduced motion the globe draws once and stays still (dragging still works).