Dotted Map

An SVG dotted world map with optional markers, staggered rows, pulse rings, and a generic renderMarkerOverlay hook for custom pins.

Default

Markers with flag clip + label pill via renderMarkerOverlay. Import flags from @/components/dotted-map/flags.

Loading…

Installation

CLI
npx @sonojs/ui@latest add dotted-map
Also needs
npm i svg-dotted-map

Usage

page.tsx
"use client"

import * as React from "react"

import { DottedMap, type Marker } from "@/components/dotted-map"
import { flagKr, flagUs } from "@/components/dotted-map/flags"

type CountryCode = "kr" | "us"

type MyMarker = Marker & {
  overlay: {
    countryCode: CountryCode
    label: string
  }
}

const FLAG_SRC: Record<CountryCode, string> = {
  kr: flagKr,
  us: flagUs,
}

const markers: MyMarker[] = [
  {
    lat: 37.5665,
    lng: 126.978,
    size: 2.8,
    overlay: { countryCode: "kr", label: "Seoul" },
  },
  {
    lat: 40.7128,
    lng: -74.006,
    size: 2.8,
    overlay: { countryCode: "us", label: "NYC" },
  },
]

export default function DottedMapDemo() {
  const id = React.useId().replace(/:/g, "")

  return (
    <div className="relative h-[400px] w-full overflow-hidden rounded-[12px] border border-border/70">
      <DottedMap<MyMarker>
        markers={markers}
        renderMarkerOverlay={({ marker, x, y, r, index }) => {
          const { countryCode, label } = marker.overlay
          const href = FLAG_SRC[countryCode]
          const imgR = r * 0.82
          const size = imgR * 2
          const clipId = `${id}-flag-${index}`
          const fontSize = r * 0.9
          const pillH = r * 1.5
          const pillW = label.length * (fontSize * 0.62) + r * 1.4
          const pillX = x + r + r * 0.6
          const pillY = y - pillH / 2

          return (
            <g key={`${countryCode}-${index}`} style={{ pointerEvents: "none" }}>
              <defs>
                <clipPath id={clipId} clipPathUnits="objectBoundingBox">
                  <circle cx={0.5} cy={0.5} r={0.5} />
                </clipPath>
              </defs>
              <image
                href={href}
                x={x - imgR}
                y={y - imgR}
                width={size}
                height={size}
                preserveAspectRatio="xMidYMid slice"
                clipPath={`url(#${clipId})`}
              />
              <rect
                x={pillX}
                y={pillY}
                width={pillW}
                height={pillH}
                rx={pillH / 2}
                fill="rgba(0,0,0,0.55)"
              />
              <text
                x={pillX + r * 0.7}
                y={y + fontSize * 0.35}
                fontSize={fontSize}
                fill="white"
              >
                {label}
              </text>
            </g>
          )
        }}
      />
    </div>
  )
}

Props

PropTypeDefaultDescription
widthnumber150Width of the SVG map coordinate space.
heightnumber75Height of the SVG map coordinate space.
mapSamplesnumber1200Landmass sample count. Land dots are painted to a cached bitmap (not one SVG node per sample). Raise only for denser maps.
markersMarker[][]Lat/lng markers projected onto the map.
dotColorstringcurrentColorFill color for landmass dots.
markerColorstring"#FF6900"Fill/stroke color for markers and pulse rings.
dotRadiusnumber0.2Radius of map sample dots.
staggerbooleantrueOffset alternating rows for a staggered look.
pulsebooleanfalseAnimate all markers (respects prefers-reduced-motion; per-marker pulse=false opts out).
renderMarkerOverlay(args) => ReactNode-Custom SVG overlay per marker (x, y, r, and projected marker).
classNamestring-Classes on the root SVG.
styleCSSProperties-Inline styles merged into the SVG.

Marker

marker.ts
export type Marker = {
  lat: number
  lng: number
  size?: number
  pulse?: boolean
}

Set pulse on DottedMap to animate all markers, or set marker.pulse per pin. Extend Marker with generics when you need overlay data.