orange-groove/react-map-annotate

Draw on Mapbox, MapLibre, Google, Leaflet, or ArcGIS in React — with your React state and your toolbar.

15

stars

21

commits

TypeScript

primary language

Sep 10, 2026

updated

react-map-annotate-demo.onrender.com/

README

@orange-groove/react-map-annotate

Draw on Mapbox, MapLibre, Google, Leaflet, or ArcGIS. The map only paints. You choose the tool, finish the shape, and persist Annotation[] like any other React state.

npm demo CI license

Live demo — Mapbox, MapLibre, Google, Leaflet, and ArcGIS.

Status: 0.3 is the public API. Pin the version. The session contract — Annotation[], setTool, finish, onChange — is what we intend to keep. Other surfaces can still change before 1.0; see the changelog and GitHub Releases.

A custom toolbar drawing a polygon; the annotations array updates in React state

Your buttons call setTool("polygon") and finish(). The map draws. onChange gives you the same Annotation[] you would save to a database.

Install

npm install @orange-groove/react-map-annotate

Peers: react and react-dom ≥ 18. Import the CSS once, or skip it and style the session yourself.

import "@orange-groove/react-map-annotate/styles.css";
MapAlso install
Mapboxreact-map-gl ≥ 8, mapbox-gl ≥ 3
MapLibrereact-map-gl ≥ 8, maplibre-gl ≥ 4
Google@vis.gl/react-google-maps ≥ 1
Leafletleaflet ≥ 1.9, react-leaflet ≥ 4 (v5 on React 19)
ArcGIS@arcgis/core ≥ 4.28

Quick start

The first snippet is headless on purpose. Stock chrome is below if you want a toolbar today.

import { useState } from "react";
import Map from "react-map-gl/mapbox";
import {
  AnnotateProvider,
  useAnnotate,
  type Annotation,
} from "@orange-groove/react-map-annotate/core";
import { Annotate } from "@orange-groove/react-map-annotate/mapbox";
import "@orange-groove/react-map-annotate/styles.css";

function FenceControls() {
  const { setTool, finish, canFinish } = useAnnotate();
  return (
    <>
      <button type="button" onClick={() => setTool("polygon")}>
        Fence
      </button>
      <button type="button" disabled={!canFinish} onClick={finish}>
        Done
      </button>
    </>
  );
}

export function MapWithDraw({ token }: { token: string }) {
  const [annotations, setAnnotations] = useState<Annotation[]>([]);

  return (
    <AnnotateProvider annotations={annotations} onChange={setAnnotations}>
      <Map
        mapboxAccessToken={token}
        initialViewState={{ longitude: -73.9857, latitude: 40.7484, zoom: 14 }}
        mapStyle="mapbox://styles/mapbox/streets-v12"
        style={{ width: "100%", height: "100%" }}
      >
        <Annotate />
      </Map>
      <FenceControls />
    </AnnotateProvider>
  );
}

Annotate must be a child of Map. Controls can live anywhere under AnnotateProvider.

Fast start: stock toolbar and list

import {
  AnnotateList,
  AnnotateToolbar,
} from "@orange-groove/react-map-annotate/core";

<AnnotateProvider annotations={annotations} onChange={setAnnotations}>
  <Map mapboxAccessToken={token} /* ... */>
    <Annotate />
  </Map>
  <AnnotateToolbar />
  <AnnotateList />
</AnnotateProvider>;

Those two components are example consumers of useAnnotateTools() and useAnnotateItems(). Replace them when your design system shows up. Full samples: examples/.

Compare

Terra Draw is a capable adapter-based drawing engine. You can drive it from your own UI (setMode, addFeatures) and read GeoJSON from its store. Use it when you want that control without a React session, or when you need OpenLayers.

This library is for when the drawing session itself is React state: the same Annotation[] your toolbar, list, and database already speak.

This libraryTerra DrawMapbox GL DrawLeaflet.DrawGoogle Drawing Manager
React state ownershipAnnotation[] on the provider. onChange is the write path.Internal GeoJSON store. Snapshot it (getSnapshot) and subscribe to change events to sync into React.Draw's feature store (getAll / set). Sync out via events.Layers on the map.Overlay objects on the map.
Custom UI APIsHeadless hooks: useAnnotate(), useAnnotateTools(), useAnnotateItems().Imperative instance API. Fully controllable; no React hooks.changeMode; hide or restyle the default control.Custom L.Control, or hide theirs.drawingControl: false + setDrawingMode.
Supported enginesMapbox, MapLibre, Google, Leaflet, ArcGISMapbox, MapLibre, Google, Leaflet, OpenLayersMapbox (MapLibre via community ports)LeafletGoogle Maps
Built-in editingMove, vertex drag, rotate, mid-edge insert, vertex delete, undo / redoSelect mode (drag, scale, rotate) plus undo / redosimple_select / direct_selectEdit / delete handlersLimited after the shape is placed
MeasurementGeodesic path, 10 m samples, optional terrain elevationNot built in. Measure from the GeoJSON you already have.Not built in.Not built in.Not built in.

Engine-locked managers (Mapbox GL Draw, Leaflet.Draw, Google Drawing Manager) are the right tool when you want their control on that one map. They were not built as a React session.

Recipes

Build a custom toolbar

import { useAnnotate } from "@orange-groove/react-map-annotate/core";

const { setTool, finish, canFinish } = useAnnotate();
setTool("polygon");
finish();

For a row of buttons with undo, redo, and icons, use useAnnotateTools() — see examples/custom-toolbar.tsx. For a sidebar that names, recolors, and deletes rows, see examples/custom-list.tsx.

Persist annotations to a database

onChange fires on add, move, resize, label, color, and delete. Put Annotation[] in the request body. Load the same array back into annotations.

<AnnotateProvider
  annotations={annotations}
  onChange={(next) => {
    setAnnotations(next);
    void fetch("/api/annotations", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(next),
    });
  }}
>

Granular onAdd / onDelete / onLabelChange / onColorChange are there when you need an audit trail. Full file: examples/persist.tsx.

Use with Zustand

The provider does not care where the array lives. Pass store getters and setters as annotations / onChange.

import { create } from "zustand";
import type { Annotation } from "@orange-groove/react-map-annotate/core";

const useAnnotations = create<{
  annotations: Annotation[];
  setAnnotations: (annotations: Annotation[]) => void;
}>((set) => ({
  annotations: [],
  setAnnotations: (annotations) => set({ annotations }),
}));

const annotations = useAnnotations((state) => state.annotations);
const setAnnotations = useAnnotations((state) => state.setAnnotations);

<AnnotateProvider annotations={annotations} onChange={setAnnotations}>

examples/zustand.tsx. Redux, Jotai, and localStorage follow the same two props.

Switch from Mapbox to MapLibre

Keep the provider, hooks, and Annotation[]. Change the map component and the Annotate import.

import Map from "react-map-gl/maplibre";
import { Annotate } from "@orange-groove/react-map-annotate/maplibre";
import "maplibre-gl/dist/maplibre-gl.css";

<Map
  initialViewState={{ longitude: -73.9857, latitude: 40.7484, zoom: 14 }}
  mapStyle="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
>
  <Annotate />
</Map>;

examples/maplibre.tsx. Other engines:

EngineAnnotate importExample
Mapbox@orange-groove/react-map-annotate or /mapboxexamples/mapbox.tsx
MapLibre/maplibreexamples/maplibre.tsx
Google/googleexamples/google.tsx
Leaflet/leafletexamples/leaflet.tsx
ArcGIS/arcgisexamples/arcgis.tsx

Session imports stay on /core. Engine entries still re-export the session so existing /mapbox (and root) imports keep working.

Mapbox enableTerrain uses the Mapbox terrain DEM. MapLibre needs an explicit raster-DEM (terrainSource). Terrain is a no-op on Google, Leaflet, and ArcGIS. Google needs a mapId (the public DEMO_MAP_ID is enough) so labels and handles can use Advanced Markers. Leaflet coordinates stay [lng, lat] in your state; isolate the map in a stacking context so panes do not cover your chrome. ArcGIS: pass the MapView through ArcgisViewProvider — do not mount React children inside MapView.container. If you render <arcgis-map>, put <Annotate /> inside it.

Create a measurement tool

const { setTool, finish, canFinish } = useAnnotate();

<button type="button" onClick={() => setTool("measure")}>
  Measure
</button>
<button type="button" disabled={!canFinish} onClick={finish}>
  Done
</button>

<Map /* ... */>
  <Annotate enableTerrain sampleIntervalMeters={10} />
</Map>

Two clicks complete a measure. The saved annotation includes geodesic distanceMeters and, with terrain enabled, elevation samples along the path. examples/measure.tsx.

Enable Trace on Google, Leaflet, and ArcGIS

Mapbox and MapLibre already know which road or building is under the pointer. Their vector styles expose queryRenderedFeatures, so Trace is on by default: hover highlights the rendered outline, click keeps kind: "trace". You do not pass a trace prop on those engines.

Google, Leaflet, and ArcGIS paint a raster basemap. There is no rendered feature graph to query, so the library cannot guess a road. You supply one: pass trace on that engine's <Annotate />. The library fires hover and click with lngLat (and the screen point) and paints whatever { coordinates } you return. The callback may be async.

Do not put this on AnnotateProvider if you also mount Mapbox or MapLibre in the same session — that replaces their built-in query.

import { Annotate } from "@orange-groove/react-map-annotate/leaflet";
import type { TraceFn } from "@orange-groove/react-map-annotate/leaflet";

const trace: TraceFn = async (lngLat) => {
  const coordinates = await lookupRoadOrBuilding(lngLat); // OSM, your GIS, …
  return coordinates ? { coordinates } : null;
};

<MapContainer center={[40.7484, -73.9857]} zoom={16}>
  <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
  <Annotate trace={trace} />
</MapContainer>;

Same prop on /google and /arcgis. lookupRoadOrBuilding is yours: fetch OSM (or Overpass), hit-test a GeoJSON layer, call an internal roads API. The live demo uses OSM for those three maps only.

Google's tiles are not OSM. If you pick from OSM on Google, the highlight can disagree with the basemap. Leaflet or ArcGIS on OSM tiles will match more closely.

trace={false} turns Trace off on every engine, including Mapbox and MapLibre.

Fonts

Pass a catalog on AnnotateProvider. The stock list uses it, new text can default to one of your families, and custom UI reads the same list from useAnnotateFonts().

import {
  AnnotateProvider,
  TEXT_FONTS,
  type AnnotateFont,
} from "@orange-groove/react-map-annotate/core";

const fonts: AnnotateFont[] = [
  ...TEXT_FONTS,
  {
    family: '"Inter"',
    label: "Inter",
    stylesheet:
      "https://fonts.googleapis.com/css2?family=Inter:wght@700&display=swap",
  },
  {
    family: "Outfit",
    label: "Outfit",
    source: "url(/fonts/outfit.woff2)",
  },
];

<AnnotateProvider fonts={fonts} defaultFontFamily='"Inter"'>
  {/* map, toolbar, list */}
</AnnotateProvider>;

stylesheet injects a <link>. source registers a FontFace. family is what gets stored on annotation.style.fontFamily and applied to the map text. Omit fonts to keep the built-in web-safe list. Spread TEXT_FONTS if you want those plus your own.

const fonts = useAnnotateFonts();
item.setStyle({ fontFamily: fonts[1]?.family });

How drawing feels

Pick a tool. Draw. Press Finish, Enter, or Escape to commit.

  • Select — click an annotation to select it. Shift-click or ⌘/Ctrl-click adds or removes. Drag an empty area to draw a dotted box; everything inside is selected. Hold Shift while dragging the box to add to the selection.
  • Trace — hover a road or building outline to highlight it. Click to keep that feature. Move the finished shape by its bounds box; it has no vertex handles.
  • Freehand, circle, rectangle — complete on mouse up.
  • Line, arrow, bidirectional arrow, measure — complete on the second click.
  • Polygon — click vertices, then Finish.
  • Marker — click to drop a pin.
  • Text — click to place. Type to edit. Corner handle resizes. Rotate handle turns it. Color from the list.
  • Edit — hover or select a finished shape to move it. End handles resize lines, arrows, and measures. Vertices resize polygons and rectangles. A diagonal handle resizes circles. A rotate handle turns drawings, rectangles, polygons, and text around their center. Hollow mid-edge handles insert vertices on polygons and paths. Double-click a vertex (or select it and press Delete) to remove it. Click empty map to deselect. Shift-click or ⌘/Ctrl-click to select more than one annotation. With the Select tool, drag a dotted rectangle to select everything inside (Shift-drag adds to the selection). Click Select again, or press Finish / Enter / Escape, to return to pan so the map can move. Selecting one member of a group selects the rest. Hover a grouped annotation to see a dotted box around the group. Drag a selected shape to move the whole selection. Vertex handles stay hidden while more than one item is selected. ⌘G / Ctrl+G groups the selection; ⇧⌘G / Ctrl+Shift+G ungroups it. Undo / redo from the toolbar or ⌘Z / ⇧⌘Z. Right-click opens Duplicate, Copy, Paste, Group, Ungroup, and Delete. ⌘D / Ctrl+D duplicates the selection to the right. ⌘C / Ctrl+C copies the selected set; ⌘V / Ctrl+V pastes it at the pointer, keeping relative spacing and group membership.
<Annotate
  enableTerrain
  defaultColor="#2563eb"
  defaultStrokeWidth={3}
  sampleIntervalMeters={10}
  renderArrowHead={({ bearing, color, size }) => (
    <svg width={size} height={size} viewBox="0 0 24 24">
      <path
        d="M12 2 L20 20 L12 16 L4 20 Z"
        fill={color}
        style={{ transform: `rotate(${bearing}deg)` }}
      />
    </svg>
  )}
  renderLabel={({ annotation }) => (
    <span className="chip">{annotation.label}</span>
  )}
/>

Measure paths are densified along the geodesic every 10 meters. With terrain enabled, each sample records ground height.

Tools

ToolWhat it does
SelectClick to select. Shift/⌘-click for more than one. Drag a dotted box to select several at once.
FreehandSketch a path. Hover for a bounds box; drag to move. Corner handle resizes; rotate handle turns it.
TraceHover a rendered road or building outline, click to adopt it. No freehand, no per-vertex handles.
LineTwo-click segment. Hover ends to resize.
Arrow / bidirectionalLine plus SVG heads. Size from the list, or setStyle({ strokeWidth }) — widens the shaft and the heads.
CircleDrag to create. Hover for a resize handle.
RectangleDrag to create. Hover vertices to resize, rotate handle to turn.
PolygonClick vertices, Finish to close. Rotate handle turns it.
MeasureGeodesic length, optional terrain samples.
MarkerLabeled map pin. Drag the pin to move it.
TextClick to place. Drag to move, corner to resize, rotate handle to turn. Color and font from the list, or setStyle({ fontFamily }). Double-click to edit.
FinishCommit the draft (same as Enter).

Mapbox and MapLibre query rendered road and building layers — no trace prop. Google, Leaflet, and ArcGIS need a trace callback; see Enable Trace on Google, Leaflet, and ArcGIS. trace={false} turns it off.

API snapshot

ExportRole
/coreSession, hooks, types, utils, toolbar, list — no Annotate.
AnnotateProviderSession. Optional annotations / onChange / fonts.
AnnotateMap child. Drawing, hover handles, layers.
AnnotateToolbarStock icon toolbar — optional.
AnnotateListStock label / color / font / size / delete list — optional.
useAnnotate()Full session: setTool, setLabel, setStyle, fonts, …
useAnnotateTools(){ items, finish, canFinish, deleteSelected, undo, redo }
useAnnotateItems()Rows with isSelected, select, setLabel, setColor, setStyle, remove.
useAnnotateFonts()Font catalog from the provider.
AnnotateToolIconBundled tool SVG.

Types ship with the package: Annotation, AnnotateTool, AnnotateSession, and the rest.

Compatibility

CI runs npm run check (typecheck, lint, Prettier, Vitest) on Node 20 and 22. Tests are jsdom unit tests, not live map tiles.

PackagePeer floorTested in this repo
react / react-dom≥ 1819.2
react-map-gl≥ 88.1
mapbox-gl≥ 33.29
maplibre-gl≥ 45.24
@vis.gl/react-google-maps≥ 11.10
leaflet≥ 1.91.9.4
react-leaflet≥ 45.0
@arcgis/core≥ 4.28peer only

React 18 and react-leaflet 4 stay in range. ArcGIS is an optional peer and is not installed in the default CI graph.

Contributing

License

MIT. Works with react-map-gl on Mapbox and MapLibre, @vis.gl/react-google-maps on Google Maps, react-leaflet on Leaflet, and ArcGIS Maps SDK for JavaScript.

Contributors

orange-groove

21 commits

orange-groove/react-map-annotate

Draw on Mapbox, MapLibre, Google, Leaflet, or ArcGIS in React — with your React state and your toolbar.

15

stars

21

commits

TypeScript

primary language

Sep 10, 2026

updated

react-map-annotate-demo.onrender.com/

README

@orange-groove/react-map-annotate

Draw on Mapbox, MapLibre, Google, Leaflet, or ArcGIS. The map only paints. You choose the tool, finish the shape, and persist Annotation[] like any other React state.

npm demo CI license

Live demo — Mapbox, MapLibre, Google, Leaflet, and ArcGIS.

Status: 0.3 is the public API. Pin the version. The session contract — Annotation[], setTool, finish, onChange — is what we intend to keep. Other surfaces can still change before 1.0; see the changelog and GitHub Releases.

A custom toolbar drawing a polygon; the annotations array updates in React state

Your buttons call setTool("polygon") and finish(). The map draws. onChange gives you the same Annotation[] you would save to a database.

Install

npm install @orange-groove/react-map-annotate

Peers: react and react-dom ≥ 18. Import the CSS once, or skip it and style the session yourself.

import "@orange-groove/react-map-annotate/styles.css";
MapAlso install
Mapboxreact-map-gl ≥ 8, mapbox-gl ≥ 3
MapLibrereact-map-gl ≥ 8, maplibre-gl ≥ 4
Google@vis.gl/react-google-maps ≥ 1
Leafletleaflet ≥ 1.9, react-leaflet ≥ 4 (v5 on React 19)
ArcGIS@arcgis/core ≥ 4.28

Quick start

The first snippet is headless on purpose. Stock chrome is below if you want a toolbar today.

import { useState } from "react";
import Map from "react-map-gl/mapbox";
import {
  AnnotateProvider,
  useAnnotate,
  type Annotation,
} from "@orange-groove/react-map-annotate/core";
import { Annotate } from "@orange-groove/react-map-annotate/mapbox";
import "@orange-groove/react-map-annotate/styles.css";

function FenceControls() {
  const { setTool, finish, canFinish } = useAnnotate();
  return (
    <>
      <button type="button" onClick={() => setTool("polygon")}>
        Fence
      </button>
      <button type="button" disabled={!canFinish} onClick={finish}>
        Done
      </button>
    </>
  );
}

export function MapWithDraw({ token }: { token: string }) {
  const [annotations, setAnnotations] = useState<Annotation[]>([]);

  return (
    <AnnotateProvider annotations={annotations} onChange={setAnnotations}>
      <Map
        mapboxAccessToken={token}
        initialViewState={{ longitude: -73.9857, latitude: 40.7484, zoom: 14 }}
        mapStyle="mapbox://styles/mapbox/streets-v12"
        style={{ width: "100%", height: "100%" }}
      >
        <Annotate />
      </Map>
      <FenceControls />
    </AnnotateProvider>
  );
}

Annotate must be a child of Map. Controls can live anywhere under AnnotateProvider.

Fast start: stock toolbar and list

import {
  AnnotateList,
  AnnotateToolbar,
} from "@orange-groove/react-map-annotate/core";

<AnnotateProvider annotations={annotations} onChange={setAnnotations}>
  <Map mapboxAccessToken={token} /* ... */>
    <Annotate />
  </Map>
  <AnnotateToolbar />
  <AnnotateList />
</AnnotateProvider>;

Those two components are example consumers of useAnnotateTools() and useAnnotateItems(). Replace them when your design system shows up. Full samples: examples/.

Compare

Terra Draw is a capable adapter-based drawing engine. You can drive it from your own UI (setMode, addFeatures) and read GeoJSON from its store. Use it when you want that control without a React session, or when you need OpenLayers.

This library is for when the drawing session itself is React state: the same Annotation[] your toolbar, list, and database already speak.

This libraryTerra DrawMapbox GL DrawLeaflet.DrawGoogle Drawing Manager
React state ownershipAnnotation[] on the provider. onChange is the write path.Internal GeoJSON store. Snapshot it (getSnapshot) and subscribe to change events to sync into React.Draw's feature store (getAll / set). Sync out via events.Layers on the map.Overlay objects on the map.
Custom UI APIsHeadless hooks: useAnnotate(), useAnnotateTools(), useAnnotateItems().Imperative instance API. Fully controllable; no React hooks.changeMode; hide or restyle the default control.Custom L.Control, or hide theirs.drawingControl: false + setDrawingMode.
Supported enginesMapbox, MapLibre, Google, Leaflet, ArcGISMapbox, MapLibre, Google, Leaflet, OpenLayersMapbox (MapLibre via community ports)LeafletGoogle Maps
Built-in editingMove, vertex drag, rotate, mid-edge insert, vertex delete, undo / redoSelect mode (drag, scale, rotate) plus undo / redosimple_select / direct_selectEdit / delete handlersLimited after the shape is placed
MeasurementGeodesic path, 10 m samples, optional terrain elevationNot built in. Measure from the GeoJSON you already have.Not built in.Not built in.Not built in.

Engine-locked managers (Mapbox GL Draw, Leaflet.Draw, Google Drawing Manager) are the right tool when you want their control on that one map. They were not built as a React session.

Recipes

Build a custom toolbar

import { useAnnotate } from "@orange-groove/react-map-annotate/core";

const { setTool, finish, canFinish } = useAnnotate();
setTool("polygon");
finish();

For a row of buttons with undo, redo, and icons, use useAnnotateTools() — see examples/custom-toolbar.tsx. For a sidebar that names, recolors, and deletes rows, see examples/custom-list.tsx.

Persist annotations to a database

onChange fires on add, move, resize, label, color, and delete. Put Annotation[] in the request body. Load the same array back into annotations.

<AnnotateProvider
  annotations={annotations}
  onChange={(next) => {
    setAnnotations(next);
    void fetch("/api/annotations", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(next),
    });
  }}
>

Granular onAdd / onDelete / onLabelChange / onColorChange are there when you need an audit trail. Full file: examples/persist.tsx.

Use with Zustand

The provider does not care where the array lives. Pass store getters and setters as annotations / onChange.

import { create } from "zustand";
import type { Annotation } from "@orange-groove/react-map-annotate/core";

const useAnnotations = create<{
  annotations: Annotation[];
  setAnnotations: (annotations: Annotation[]) => void;
}>((set) => ({
  annotations: [],
  setAnnotations: (annotations) => set({ annotations }),
}));

const annotations = useAnnotations((state) => state.annotations);
const setAnnotations = useAnnotations((state) => state.setAnnotations);

<AnnotateProvider annotations={annotations} onChange={setAnnotations}>

examples/zustand.tsx. Redux, Jotai, and localStorage follow the same two props.

Switch from Mapbox to MapLibre

Keep the provider, hooks, and Annotation[]. Change the map component and the Annotate import.

import Map from "react-map-gl/maplibre";
import { Annotate } from "@orange-groove/react-map-annotate/maplibre";
import "maplibre-gl/dist/maplibre-gl.css";

<Map
  initialViewState={{ longitude: -73.9857, latitude: 40.7484, zoom: 14 }}
  mapStyle="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
>
  <Annotate />
</Map>;

examples/maplibre.tsx. Other engines:

EngineAnnotate importExample
Mapbox@orange-groove/react-map-annotate or /mapboxexamples/mapbox.tsx
MapLibre/maplibreexamples/maplibre.tsx
Google/googleexamples/google.tsx
Leaflet/leafletexamples/leaflet.tsx
ArcGIS/arcgisexamples/arcgis.tsx

Session imports stay on /core. Engine entries still re-export the session so existing /mapbox (and root) imports keep working.

Mapbox enableTerrain uses the Mapbox terrain DEM. MapLibre needs an explicit raster-DEM (terrainSource). Terrain is a no-op on Google, Leaflet, and ArcGIS. Google needs a mapId (the public DEMO_MAP_ID is enough) so labels and handles can use Advanced Markers. Leaflet coordinates stay [lng, lat] in your state; isolate the map in a stacking context so panes do not cover your chrome. ArcGIS: pass the MapView through ArcgisViewProvider — do not mount React children inside MapView.container. If you render <arcgis-map>, put <Annotate /> inside it.

Create a measurement tool

const { setTool, finish, canFinish } = useAnnotate();

<button type="button" onClick={() => setTool("measure")}>
  Measure
</button>
<button type="button" disabled={!canFinish} onClick={finish}>
  Done
</button>

<Map /* ... */>
  <Annotate enableTerrain sampleIntervalMeters={10} />
</Map>

Two clicks complete a measure. The saved annotation includes geodesic distanceMeters and, with terrain enabled, elevation samples along the path. examples/measure.tsx.

Enable Trace on Google, Leaflet, and ArcGIS

Mapbox and MapLibre already know which road or building is under the pointer. Their vector styles expose queryRenderedFeatures, so Trace is on by default: hover highlights the rendered outline, click keeps kind: "trace". You do not pass a trace prop on those engines.

Google, Leaflet, and ArcGIS paint a raster basemap. There is no rendered feature graph to query, so the library cannot guess a road. You supply one: pass trace on that engine's <Annotate />. The library fires hover and click with lngLat (and the screen point) and paints whatever { coordinates } you return. The callback may be async.

Do not put this on AnnotateProvider if you also mount Mapbox or MapLibre in the same session — that replaces their built-in query.

import { Annotate } from "@orange-groove/react-map-annotate/leaflet";
import type { TraceFn } from "@orange-groove/react-map-annotate/leaflet";

const trace: TraceFn = async (lngLat) => {
  const coordinates = await lookupRoadOrBuilding(lngLat); // OSM, your GIS, …
  return coordinates ? { coordinates } : null;
};

<MapContainer center={[40.7484, -73.9857]} zoom={16}>
  <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
  <Annotate trace={trace} />
</MapContainer>;

Same prop on /google and /arcgis. lookupRoadOrBuilding is yours: fetch OSM (or Overpass), hit-test a GeoJSON layer, call an internal roads API. The live demo uses OSM for those three maps only.

Google's tiles are not OSM. If you pick from OSM on Google, the highlight can disagree with the basemap. Leaflet or ArcGIS on OSM tiles will match more closely.

trace={false} turns Trace off on every engine, including Mapbox and MapLibre.

Fonts

Pass a catalog on AnnotateProvider. The stock list uses it, new text can default to one of your families, and custom UI reads the same list from useAnnotateFonts().

import {
  AnnotateProvider,
  TEXT_FONTS,
  type AnnotateFont,
} from "@orange-groove/react-map-annotate/core";

const fonts: AnnotateFont[] = [
  ...TEXT_FONTS,
  {
    family: '"Inter"',
    label: "Inter",
    stylesheet:
      "https://fonts.googleapis.com/css2?family=Inter:wght@700&display=swap",
  },
  {
    family: "Outfit",
    label: "Outfit",
    source: "url(/fonts/outfit.woff2)",
  },
];

<AnnotateProvider fonts={fonts} defaultFontFamily='"Inter"'>
  {/* map, toolbar, list */}
</AnnotateProvider>;

stylesheet injects a <link>. source registers a FontFace. family is what gets stored on annotation.style.fontFamily and applied to the map text. Omit fonts to keep the built-in web-safe list. Spread TEXT_FONTS if you want those plus your own.

const fonts = useAnnotateFonts();
item.setStyle({ fontFamily: fonts[1]?.family });

How drawing feels

Pick a tool. Draw. Press Finish, Enter, or Escape to commit.

  • Select — click an annotation to select it. Shift-click or ⌘/Ctrl-click adds or removes. Drag an empty area to draw a dotted box; everything inside is selected. Hold Shift while dragging the box to add to the selection.
  • Trace — hover a road or building outline to highlight it. Click to keep that feature. Move the finished shape by its bounds box; it has no vertex handles.
  • Freehand, circle, rectangle — complete on mouse up.
  • Line, arrow, bidirectional arrow, measure — complete on the second click.
  • Polygon — click vertices, then Finish.
  • Marker — click to drop a pin.
  • Text — click to place. Type to edit. Corner handle resizes. Rotate handle turns it. Color from the list.
  • Edit — hover or select a finished shape to move it. End handles resize lines, arrows, and measures. Vertices resize polygons and rectangles. A diagonal handle resizes circles. A rotate handle turns drawings, rectangles, polygons, and text around their center. Hollow mid-edge handles insert vertices on polygons and paths. Double-click a vertex (or select it and press Delete) to remove it. Click empty map to deselect. Shift-click or ⌘/Ctrl-click to select more than one annotation. With the Select tool, drag a dotted rectangle to select everything inside (Shift-drag adds to the selection). Click Select again, or press Finish / Enter / Escape, to return to pan so the map can move. Selecting one member of a group selects the rest. Hover a grouped annotation to see a dotted box around the group. Drag a selected shape to move the whole selection. Vertex handles stay hidden while more than one item is selected. ⌘G / Ctrl+G groups the selection; ⇧⌘G / Ctrl+Shift+G ungroups it. Undo / redo from the toolbar or ⌘Z / ⇧⌘Z. Right-click opens Duplicate, Copy, Paste, Group, Ungroup, and Delete. ⌘D / Ctrl+D duplicates the selection to the right. ⌘C / Ctrl+C copies the selected set; ⌘V / Ctrl+V pastes it at the pointer, keeping relative spacing and group membership.
<Annotate
  enableTerrain
  defaultColor="#2563eb"
  defaultStrokeWidth={3}
  sampleIntervalMeters={10}
  renderArrowHead={({ bearing, color, size }) => (
    <svg width={size} height={size} viewBox="0 0 24 24">
      <path
        d="M12 2 L20 20 L12 16 L4 20 Z"
        fill={color}
        style={{ transform: `rotate(${bearing}deg)` }}
      />
    </svg>
  )}
  renderLabel={({ annotation }) => (
    <span className="chip">{annotation.label}</span>
  )}
/>

Measure paths are densified along the geodesic every 10 meters. With terrain enabled, each sample records ground height.

Tools

ToolWhat it does
SelectClick to select. Shift/⌘-click for more than one. Drag a dotted box to select several at once.
FreehandSketch a path. Hover for a bounds box; drag to move. Corner handle resizes; rotate handle turns it.
TraceHover a rendered road or building outline, click to adopt it. No freehand, no per-vertex handles.
LineTwo-click segment. Hover ends to resize.
Arrow / bidirectionalLine plus SVG heads. Size from the list, or setStyle({ strokeWidth }) — widens the shaft and the heads.
CircleDrag to create. Hover for a resize handle.
RectangleDrag to create. Hover vertices to resize, rotate handle to turn.
PolygonClick vertices, Finish to close. Rotate handle turns it.
MeasureGeodesic length, optional terrain samples.
MarkerLabeled map pin. Drag the pin to move it.
TextClick to place. Drag to move, corner to resize, rotate handle to turn. Color and font from the list, or setStyle({ fontFamily }). Double-click to edit.
FinishCommit the draft (same as Enter).

Mapbox and MapLibre query rendered road and building layers — no trace prop. Google, Leaflet, and ArcGIS need a trace callback; see Enable Trace on Google, Leaflet, and ArcGIS. trace={false} turns it off.

API snapshot

ExportRole
/coreSession, hooks, types, utils, toolbar, list — no Annotate.
AnnotateProviderSession. Optional annotations / onChange / fonts.
AnnotateMap child. Drawing, hover handles, layers.
AnnotateToolbarStock icon toolbar — optional.
AnnotateListStock label / color / font / size / delete list — optional.
useAnnotate()Full session: setTool, setLabel, setStyle, fonts, …
useAnnotateTools(){ items, finish, canFinish, deleteSelected, undo, redo }
useAnnotateItems()Rows with isSelected, select, setLabel, setColor, setStyle, remove.
useAnnotateFonts()Font catalog from the provider.
AnnotateToolIconBundled tool SVG.

Types ship with the package: Annotation, AnnotateTool, AnnotateSession, and the rest.

Compatibility

CI runs npm run check (typecheck, lint, Prettier, Vitest) on Node 20 and 22. Tests are jsdom unit tests, not live map tiles.

PackagePeer floorTested in this repo
react / react-dom≥ 1819.2
react-map-gl≥ 88.1
mapbox-gl≥ 33.29
maplibre-gl≥ 45.24
@vis.gl/react-google-maps≥ 11.10
leaflet≥ 1.91.9.4
react-leaflet≥ 45.0
@arcgis/core≥ 4.28peer only

React 18 and react-leaflet 4 stay in range. ArcGIS is an optional peer and is not installed in the default CI graph.

Contributing

License

MIT. Works with react-map-gl on Mapbox and MapLibre, @vis.gl/react-google-maps on Google Maps, react-leaflet on Leaflet, and ArcGIS Maps SDK for JavaScript.

Contributors

orange-groove

21 commits

Languages

TypeScript

97.5%

CSS

2.3%