Skip to content

Components / Backgrounds

Dotted Map

A world map drawn as a grid of dots, with markers that pulse. The land is a 1° bitmask from Natural Earth, drawn on the server as one <path>: each run of land cells in a row is a segment, and a dash pattern of zero-length dashes with round caps turns it into dots: a few hundred segments, not five thousand circles. Markers ping on a delay from the stagger token and carry a title. Zero JS.

Live demo

JakartaTokyoLondonNew YorkSão Paulo
  • Jakarta
  • Tokyo
  • London
  • New York
  • São Paulo

one path · five cities

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

Measured

JavaScript of its own

0 B

CSS 4.9 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 DottedMap from '@moonarc/core/DottedMap';
---
<div class="relative aspect-[2/1]">
  <DottedMap
    markers={[
      { lat: 40.7, lng: -74, label: 'New York' },
      { lat: 51.5, lng: -0.1, label: 'London' },
      { lat: 1.3, lng: 103.8, label: 'Singapore' },
    ]}
    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 and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/dotted-map.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 and geo.ts.

DottedMap.astro
---
/**
 * DottedMap — the world as a grid of dots, with markers that pulse. The
 * land comes from a 1° bitmask (Natural Earth 110m, built once into
 * lib/land.ts) and is drawn on the server as one <path>: every run of land
 * cells in a row is a horizontal segment, and a dash pattern of zero-length
 * dashes with round caps turns the segment into dots on the grid — so a
 * 2° map is a few hundred segments, not five thousand circles. Markers are
 * a dot and a ring scaling out on a delay from the stagger token; each
 * carries a <title> and the labels are listed for readers. Zero script.
 */
import type { HTMLAttributes } from 'astro/types';
import { LAND, LAND_H, LAND_W } from '../lib/land';
import { decode, maskAt, toMap, type Marker } from '../lib/geo';

interface Props extends HTMLAttributes<'div'> {
  /** Cities to mark: latitude, longitude and an optional label. */
  markers?: Marker[];
  /** Grid spacing: low 3°, medium 2°, high 1.5°. */
  density?: 'low' | 'medium' | 'high';
  /** Dot colour. */
  color?: string;
  /** Marker colour. */
  accent?: string;
  /** Pulse the markers. */
  ping?: boolean;
  /** Seconds per pulse. */
  duration?: number;
  /** Include Antarctica. */
  antarctica?: boolean;
  /** Accessible name; the marker labels are appended. */
  label?: string;
}

const { markers = [], density = 'medium', color = 'color-mix(in srgb, var(--ma-ink) 32%, transparent)', accent = 'currentColor', ping = true, duration = 2.4, antarctica = false, label = 'World map', class: className, style, ...rest } = Astro.props;
const step = density === 'low' ? 3 : density === 'high' ? 1.5 : 2;
const mask = decode(LAND, LAND_W, LAND_H);
const south = antarctica ? -90 : -58;
const north = 84;
// the drawing is 200 × 100 (2:1, equirectangular); one path, one `M x y h len` per run of land cells in a row
const W = 200;
const segs: string[] = [];
let dots = 0;
for (let lat = north - step / 2; lat > south; lat -= step) {
  let run = 0;
  let x0 = 0;
  const y = Math.round(((90 - lat) / 180) * 100 * 100) / 100;
  const cols = Math.round(360 / step);
  for (let i = 0; i <= cols; i++) {
    const lng = -180 + (i + 0.5) * step;
    const land = i < cols && maskAt(mask, lat, lng);
    if (land) {
      if (run === 0) x0 = Math.round(((lng + 180) / 360) * W * 100) / 100;
      run++;
      dots++;
    } else if (run) {
      segs.push(`M${x0} ${y}h${Math.round((run - 1) * step * (W / 360) * 100) / 100}`);
      run = 0;
    }
  }
}
const d = segs.join('');
const pins = markers.map((m, i) => {
  const [x, y] = toMap(m.lat, m.lng);
  return { ...m, i, x: Math.round(x * W) / 100, y: Math.round(y * 100) / 100 };
});
const viewTop = ((90 - north) / 180) * 100;
const viewH = ((north - south) / 180) * 100;
const name = pins.length ? `${label}: ${pins.map((p) => p.label ?? `${p.lat}, ${p.lng}`).join(', ')}` : label;
const inline = [`--ma-map-step:${(step * W) / 360}`, `--ma-map-color:${color}`, `--ma-map-accent:${accent}`, `--ma-map-dur:${duration}s`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-map', className]} data-ma-map data-dots={dots} data-path-bytes={d.length} style={inline} {...rest}>
  <svg class="ma-map__svg" viewBox={`0 ${viewTop} ${W} ${viewH}`} preserveAspectRatio="xMidYMid meet" role="img" aria-label={name}>
    <path class="ma-map__land" d={d} />
    {pins.map((p) => (
      <g class="ma-map__pin" transform={`translate(${p.x} ${p.y})`} style={`--ma-map-i:${p.i}`}>
        {p.label && <title>{p.label}</title>}
        {ping && <circle class="ma-map__ring" r="1" />}
        <circle class="ma-map__dot" r="0.9" />
      </g>
    ))}
  </svg>
  {pins.some((p) => p.label) && (
    <ul class="ma-map__list">
      {pins.map((p) => p.label && <li>{p.label}</li>)}
    </ul>
  )}
</div>

<style is:global>
  @layer components {
    :where(.ma-map) {
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      pointer-events: none;
    }
    :where(.ma-map__svg) {
      width: 100%;
      height: 100%;
      overflow: visible;
    }
    /* dots on the grid: zero-length dashes, one per step, round caps — the dot radius is 0.42 of the step, in map units */
    :where(.ma-map__land) {
      fill: none;
      stroke: var(--ma-map-color);
      stroke-width: calc(var(--ma-map-step, 1.11) * 0.84);
      stroke-linecap: round;
      stroke-dasharray: 0 var(--ma-map-step, 1.11);
    }
    :where(.ma-map__dot) {
      fill: var(--ma-map-accent, currentColor);
    }
    :where(.ma-map__ring) {
      fill: none;
      stroke: var(--ma-map-accent, currentColor);
      stroke-width: 0.35;
      transform-box: fill-box;
      transform-origin: center;
      opacity: 0;
      animation: ma-map-ping var(--ma-map-dur) var(--ma-ease-out) infinite;
      animation-delay: calc(var(--ma-map-i, 0) * var(--ma-stagger-relaxed) * 3);
    }
    :where(.ma-map__list) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-map__ring) {
        animation: none;
        opacity: 0.5;
        scale: 2;
      }
    }
  }

  @keyframes ma-map-ping {
    0% {
      opacity: 0.9;
      scale: 1;
    }
    100% {
      opacity: 0;
      scale: 4;
    }
  }
</style>

Props

PropTypeDefaultDescription
markers{ lat, lng, label? }[][]Cities to mark; a label becomes a <title> and a line in the hidden list.
density'low' | 'medium' | 'high''medium'Grid spacing: 3°, 2° or 1.5°. Denser is more HTML; the path size is on the card.
colorstringink at 32 %Dot colour.
accentstring'currentColor'Marker colour.
pingbooleantruePulse the markers.
durationnumber2.4Seconds per pulse.
antarcticabooleanfalseInclude Antarctica (the map is cut at 58° S otherwise).
labelstring'World map'Accessible name; marker labels are appended.

Reduced motion

The rings stand still around the markers at half strength.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: DottedMap (Magic UI) · WorldMap (Aceternity) · dotted-map (npm). See the migration table.

Dot Pattern

Backgrounds

A field of dots behind content, faded toward the edges, with an optional slow pulse.

every browseralways

Globe

Backgrounds

A turning globe of dots that you can drag.

every browseralwayspointer

Ripple

Backgrounds

Rings expand from a point and fade, one after another, like a pulse.

every browseralways

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