Skip to content

Components / Backgrounds

Globe

A turning globe of dots that you can drag. The land is the same 1° bitmask DottedMap draws from, sent as a downsampled mask in a JSON block and decoded on the client into rows of dots spread evenly by latitude; each frame projects them orthographically, front side only, depth as alpha, in three batched fills. Markers pulse on the ambient token. Drag has inertia; touch-action keeps the page scrolling. An SVG globe stands in without JavaScript.

Live demo

drag

one mask · three fills

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

2.8 kB raw

1.4 kB gzip.

Uses the shared runtime (1.9 kB raw, once per site) and the shared canvas helper (2.1 kB raw, once per site). With those included: 6.1 kB raw.

CSS 5.1 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Globe from '@moonarc/core/Globe';
---
<div class="relative aspect-square max-w-lg">
  <Globe
    markers={[
      { lat: 35.7, lng: 139.7, label: 'Tokyo' },
      { lat: -6.2, lng: 106.8, label: 'Jakarta' },
      { lat: 52.5, lng: 13.4, label: 'Berlin' },
    ]}
    accent="var(--action)"
  />
</div>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the land mask, the geography helper, the shared runtime, the canvas helper and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/globe.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy land.ts, geo.ts, runtime.ts and canvas.ts.

Globe.astro
---
/**
 * Globe — the world as dots on a sphere, turning, and yours to drag. The
 * land is the same 1° bitmask DottedMap draws from, sent here as a
 * downsampled mask in a JSON block (a few kB of HTML, printed on the card)
 * and decoded on the client into rows of dots spread evenly by latitude;
 * the script projects them orthographically each frame, front side only,
 * depth as alpha, in three batched fills. Markers pulse on the front side
 * on the ambient duration token. Drag turns it with inertia; the friction,
 * the drag gain and the tilt are custom properties, the speed a prop.
 * touch-action: pan-y keeps the page scrolling on a phone. Without
 * JavaScript an SVG globe with a graticule stands in; under reduced motion
 * one frame is drawn and it turns only while dragged. Colours are the
 * theme's, re-read when it changes. Fills its positioned parent.
 */
import type { HTMLAttributes } from 'astro/types';
import { LAND, LAND_H, LAND_W } from '../lib/land';
import { decode, downsample, type Marker } from '../lib/geo';

interface Props extends HTMLAttributes<'div'> {
  /** Cities to mark: latitude, longitude, optional label. */
  markers?: Marker[];
  /** Degrees per second of auto-rotation; 0 stops it. */
  speed?: number;
  /** Turn with the pointer. */
  draggable?: boolean;
  /** Dot spacing: low 4°, medium 3°, high 2°. */
  density?: 'low' | 'medium' | 'high';
  /** Longitude facing the viewer at the start. */
  lng?: number;
  /** Tilt toward the viewer in degrees. */
  tilt?: number;
  /** Dot colour. */
  color?: string;
  /** Sphere colour. */
  sea?: string;
  /** Marker colour. */
  accent?: string;
  /** Accessible name; the marker labels are appended. */
  label?: string;
}

const { markers = [], speed = 8, draggable = true, density = 'medium', lng = -20, tilt = 18, color = 'color-mix(in srgb, var(--ma-ink) 70%, transparent)', sea = 'color-mix(in srgb, var(--ma-ink) 7%, transparent)', accent = 'currentColor', label = 'Globe', class: className, style, ...rest } = Astro.props;
const step = density === 'low' ? 4 : density === 'high' ? 2 : 3;
const data = downsample(decode(LAND, LAND_W, LAND_H), step);
const json = JSON.stringify({ w: data.w, h: data.h, step, mask: data.b64 });
const name = markers.length ? `${label}: ${markers.map((m) => m.label ?? `${m.lat}, ${m.lng}`).join(', ')}` : label;
const inline = [`--ma-globe-speed:${speed}`, `--ma-globe-lng:${lng}`, `--ma-globe-tilt:${tilt}`, `--ma-globe-color:${color}`, `--ma-globe-sea:${sea}`, `--ma-globe-accent:${accent}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const rings = [-60, -30, 0, 30, 60];
// the fallback's gradient, one per globe: a second globe on the page must not share the first one's id
const gradient = `ma-globe-g-${Math.random().toString(36).slice(2, 7)}`;
---

<div class:list={['ma-globe', className]} data-ma-globe data-drag={draggable ? '' : undefined} data-markers={markers.length ? JSON.stringify(markers.map((m) => [m.lat, m.lng])) : undefined} data-bytes={json.length} role="img" aria-label={name} style={inline} {...rest}>
  <svg class="ma-globe__fallback" viewBox="-1 -1 2 2" aria-hidden="true">
    <defs>
      <radialGradient id={gradient} cx="35%" cy="30%" r="80%">
        <stop offset="0" stop-color="var(--ma-globe-sea)" stop-opacity="1" />
        <stop offset="1" stop-color="var(--ma-globe-sea)" stop-opacity="0.4" />
      </radialGradient>
    </defs>
    <circle r="0.98" fill={`url(#${gradient})`} />
    <g class="ma-globe__grat">
      {rings.map((la) => {
        const y = -Math.sin((la * Math.PI) / 180) * 0.98;
        const rx = Math.cos((la * Math.PI) / 180) * 0.98;
        return <ellipse cx="0" cy={y.toFixed(3)} rx={rx.toFixed(3)} ry={(rx * 0.18).toFixed(3)} />;
      })}
      {[0, 30, 60].map((lo) => <ellipse cx="0" cy="0" rx={(Math.sin((lo * Math.PI) / 180) * 0.98).toFixed(3)} ry="0.98" />)}
      <circle r="0.98" />
    </g>
  </svg>
  <canvas aria-hidden="true"></canvas>
  <script is:inline type="application/json" set:html={json} />
  {markers.some((m) => m.label) && (
    <ul class="ma-globe__list">
      {markers.map((m) => m.label && <li>{m.label}</li>)}
    </ul>
  )}
</div>

<style is:global>
  @layer components {
    :where(.ma-globe) {
      position: absolute;
      inset: 0;
      overflow: hidden;
      contain: strict;
      /* drag gain (radians per radius of travel), friction (velocity kept per frame), the ring's reach in radii */
      --ma-globe-gain: 1.4;
      --ma-globe-friction: 0.95;
      --ma-globe-ping: 0.09;
    }
    :where([data-ma-preset='snap'] .ma-globe) {
      --ma-globe-friction: 0.9;
    }
    :where([data-ma-preset='ambient'] .ma-globe),
    :where([data-ma-preset='gentle'] .ma-globe) {
      --ma-globe-friction: 0.975;
    }
    :where(.ma-globe__fallback) {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      transition: opacity var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-globe__grat) {
      fill: none;
      stroke: var(--ma-globe-color);
      stroke-width: 0.006;
      opacity: 0.35;
    }
    :where(.ma-globe[data-live] .ma-globe__fallback) {
      opacity: 0;
    }
    :where(.ma-globe canvas) {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      touch-action: pan-y;
      /* the theme-resolved colours the script reads: sea, marker, land. The canvas keeps the globe's own color, so a
         currentColor accent (the default) is the text colour around the globe, not the land colour */
      border: 0 solid;
      border-top-color: var(--ma-globe-sea);
      border-right-color: var(--ma-globe-accent);
      border-left-color: var(--ma-globe-color);
    }
    :where(.ma-globe[data-drag] canvas) {
      cursor: grab;
    }
    :where(.ma-globe[data-dragging] canvas) {
      cursor: grabbing;
    }
    :where(.ma-globe__list) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-globe__fallback) {
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount, prefersReducedMotion } from '../lib/runtime';
  import { colors, fit, loop, ms, num, onTheme, visible } from '../lib/canvas';
  import { decode, landRows, toSphere } from '../lib/geo';

  onMount<HTMLElement>('[data-ma-globe]', (el, { signal }) => {
    const canvas = el.querySelector('canvas')!;
    const data = JSON.parse(el.querySelector('script')!.textContent!) as { w: number; h: number; step: number; mask: string };
    const pts = landRows(decode(data.mask, data.w, data.h), data.step).map(([la, lo]) => toSphere(la, lo));
    const marks = (JSON.parse(el.dataset.markers ?? '[]') as [number, number][]).map(([la, lo]) => toSphere(la, lo));
    const v = (n: string, f: number) => num(el, '--ma-globe-' + n, f);
    const rad = Math.PI / 180;
    let ctx: CanvasRenderingContext2D;
    let w = 0;
    let h = 0;
    let R = 0;
    let phi = -v('lng', -20) * rad;
    let theta = v('tilt', 18) * rad;
    let vphi = 0;
    let vtheta = 0;
    let drag = false;
    let land = '';
    let sea = '';
    let accent = '';
    let period = 1;
    let dotR = 1;
    const theme = () => ([, sea, accent, , land] = colors(canvas));
    const frame = (t: number, dt: number) => {
      if (!drag) {
        phi += (v('speed', 8) * rad * dt) / 1000 + vphi;
        theta = Math.max(-1.3, Math.min(1.3, theta + vtheta));
        const fr = v('friction', 0.95);
        vphi *= fr;
        vtheta *= fr;
      }
      const cx = w / 2;
      const cy = h / 2;
      const cp = Math.cos(phi);
      const sp = Math.sin(phi);
      const ct = Math.cos(theta);
      const st = Math.sin(theta);
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = sea;
      ctx.beginPath();
      ctx.arc(cx, cy, R, 0, 7);
      ctx.fill();
      // project once, bucket by depth, three fills
      const buckets: number[][] = [[], [], []];
      for (const [x, y, z] of pts) {
        const x1 = x * cp + z * sp;
        const z1 = z * cp - x * sp;
        const y1 = y * ct - z1 * st;
        const z2 = y * st + z1 * ct;
        if (z2 > 0) buckets[Math.min(2, (z2 * 3) | 0)]!.push(cx + x1 * R, cy - y1 * R);
      }
      ctx.fillStyle = land;
      buckets.forEach((b, i) => {
        ctx.globalAlpha = 0.3 + i * 0.35;
        ctx.beginPath();
        for (let k = 0; k < b.length; k += 2) {
          ctx.moveTo(b[k]! + dotR, b[k + 1]!);
          ctx.arc(b[k]!, b[k + 1]!, dotR, 0, 7);
        }
        ctx.fill();
      });
      ctx.globalAlpha = 1;
      const ping = (t % period) / period;
      for (const [x, y, z] of marks) {
        const x1 = x * cp + z * sp;
        const z1 = z * cp - x * sp;
        const y1 = y * ct - z1 * st;
        if (y * st + z1 * ct <= 0) continue;
        const mx = cx + x1 * R;
        const my = cy - y1 * R;
        ctx.fillStyle = accent;
        ctx.beginPath();
        ctx.arc(mx, my, dotR * 1.8, 0, 7);
        ctx.fill();
        ctx.strokeStyle = accent;
        ctx.globalAlpha = 1 - ping;
        ctx.lineWidth = dotR * 0.6;
        ctx.beginPath();
        ctx.arc(mx, my, dotR * 1.8 + ping * v('ping', 0.09) * R, 0, 7);
        ctx.stroke();
        ctx.globalAlpha = 1;
      }
    };
    const run = loop(frame, signal);
    // reduced motion is read live (the page's switch stops a turning globe): one still frame, frames only while dragging
    const wake = () => (prefersReducedMotion() && !drag ? (run.sleep(), run.once()) : run.wake());
    fit(canvas, el, signal, (W, H, c) => {
      w = W;
      h = H;
      ctx = c;
      R = (Math.min(w, h) / 2) * 0.92;
      dotR = Math.max(0.6, ((R * data.step * rad) / 2) * 0.55);
      period = ms(el, '--ma-dur-ambient', 1400) * 2;
      run.once();
    });
    theme();
    let on = false;
    onTheme(signal, () => {
      theme();
      on ? wake() : run.once();
    });
    el.setAttribute('data-live', '');
    visible(el, signal, (o) => ((on = o) ? wake() : run.sleep()));
    if (el.hasAttribute('data-drag')) {
      let lx = 0;
      let ly = 0;
      let lt = 0;
      canvas.addEventListener(
        'pointerdown',
        (e) => {
          drag = true;
          el.setAttribute('data-dragging', '');
          canvas.setPointerCapture(e.pointerId);
          lx = e.clientX;
          ly = e.clientY;
          lt = e.timeStamp;
          vphi = vtheta = 0;
          run.wake();
        },
        { signal },
      );
      canvas.addEventListener(
        'pointermove',
        (e) => {
          if (!drag) return;
          const g = v('gain', 1.4) / R;
          const dp = (e.clientX - lx) * g;
          const dth = (e.clientY - ly) * g;
          phi += dp;
          theta = Math.max(-1.3, Math.min(1.3, theta + dth));
          const k = 16 / Math.max(1, e.timeStamp - lt);
          vphi = dp * k;
          vtheta = dth * k;
          lx = e.clientX;
          ly = e.clientY;
          lt = e.timeStamp;
        },
        { signal, passive: true },
      );
      const up = () => {
        drag = false;
        el.removeAttribute('data-dragging');
        if (on) wake();
      };
      canvas.addEventListener('pointerup', up, { signal });
      canvas.addEventListener('pointercancel', up, { signal });
    }
  });
</script>

Props

PropTypeDefaultDescription
markers{ lat, lng, label? }[][]Cities to mark; labels go into the accessible name and a hidden list.
speednumber8Degrees per second of auto-rotation; 0 stops it.
draggablebooleantrueTurn with the pointer; released with inertia on the preset friction.
density'low' | 'medium' | 'high''medium'Dot spacing: 4°, 3° or 2°. Denser is more data; the JSON size is on the card.
lngnumber-20Longitude facing the viewer at the start.
tiltnumber18Tilt toward the viewer in degrees.
colorstringink at 70 %Dot colour.
seastringink at 7 %Sphere colour.
accentstring'currentColor'Marker colour; currentColor is the globe's text colour, inherited from around it unless you set one.
labelstring'Globe'Accessible name; marker labels are appended.

Reduced motion

One frame is drawn and the globe does not turn on its own; it still turns while dragged (direct manipulation) and rests when released.

With ClientRouter

Bound through the shared runtime: the loop, the observers, the pointer capture and the listeners are released before the swap; five round trips leave one loop per instance.

Why it is built this way

Replaces: Globe (Magic UI, cobe) · GitHubGlobe (Aceternity, three.js) · cobe. See the migration table.

Dotted Map

Backgrounds

A world map drawn as a grid of dots, with markers that pulse.

every browseralways

Orbiting Circles

Backgrounds

The children orbit the centre of their parent and stay upright.

every browseralways

Particles

Backgrounds

A field of drifting dots that part around the cursor, on a canvas that draws only while on screen in a visible tab.

every browseralwayspointer

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown