Dot Pattern
BackgroundsA field of dots behind content, faded toward the edges, with an optional slow pulse.
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.
one path · five cities
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
every browser.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the land mask, the geography helper and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/dotted-map.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
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 — 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>| Prop | Type | Default | Description |
|---|---|---|---|
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. |
color | string | ink at 32 % | Dot colour. |
accent | string | 'currentColor' | Marker colour. |
ping | boolean | true | Pulse the markers. |
duration | number | 2.4 | Seconds per pulse. |
antarctica | boolean | false | Include Antarctica (the map is cut at 58° S otherwise). |
label | string | 'World map' | Accessible name; marker labels are appended. |
The rings stand still around the markers at half strength.
ClientRouterCSS-only; nothing to rebind.
Replaces: DottedMap (Magic UI) · WorldMap (Aceternity) · dotted-map (npm). See the migration table.
A field of dots behind content, faded toward the edges, with an optional slow pulse.
Rings expand from a point and fade, one after another, like a pulse.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown