Skip to content

Dotted Globe

A rotating globe of dots with arcs; drag to spin.

Drag to spin.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/dotted-globe.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export 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]);
    }
  3. 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

PropTypeDefaultDescription
markersGlobeMarker[]–Highlighted locations.
arcsGlobeArc[]–Arcs between locations, drawn as lifted great circles.
dotColorstring"var(--foreground)"Land dot color (any CSS color, including a theme variable).
accentColorstring"var(--primary)"Marker and arc color (any CSS color, including a theme variable).
speednumber8Auto-rotation in degrees per second.
draggablebooleantrueDrag to rotate.
labelstring"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).