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
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.
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.

Your buttons call setTool("polygon") and finish(). The map draws. onChange
gives you the same Annotation[] you would save to a database.
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";
| Map | Also install |
|---|---|
| Mapbox | react-map-gl ≥ 8, mapbox-gl ≥ 3 |
| MapLibre | react-map-gl ≥ 8, maplibre-gl ≥ 4 |
@vis.gl/react-google-maps ≥ 1 | |
| Leaflet | leaflet ≥ 1.9, react-leaflet ≥ 4 (v5 on React 19) |
| ArcGIS | @arcgis/core ≥ 4.28 |
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.
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/.
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 library | Terra Draw | Mapbox GL Draw | Leaflet.Draw | Google Drawing Manager | |
|---|---|---|---|---|---|
| React state ownership | Annotation[] 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 APIs | Headless 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 engines | Mapbox, MapLibre, Google, Leaflet, ArcGIS | Mapbox, MapLibre, Google, Leaflet, OpenLayers | Mapbox (MapLibre via community ports) | Leaflet | Google Maps |
| Built-in editing | Move, vertex drag, rotate, mid-edge insert, vertex delete, undo / redo | Select mode (drag, scale, rotate) plus undo / redo | simple_select / direct_select | Edit / delete handlers | Limited after the shape is placed |
| Measurement | Geodesic path, 10 m samples, optional terrain elevation | Not 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.
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.
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.
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.
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:
| Engine | Annotate import | Example |
|---|---|---|
| Mapbox | @orange-groove/react-map-annotate or /mapbox | examples/mapbox.tsx |
| MapLibre | /maplibre | examples/maplibre.tsx |
/google | examples/google.tsx | |
| Leaflet | /leaflet | examples/leaflet.tsx |
| ArcGIS | /arcgis | examples/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.
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.
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.
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 });
Pick a tool. Draw. Press Finish, Enter, or Escape to commit.
<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.
| Tool | What it does |
|---|---|
| Select | Click to select. Shift/⌘-click for more than one. Drag a dotted box to select several at once. |
| Freehand | Sketch a path. Hover for a bounds box; drag to move. Corner handle resizes; rotate handle turns it. |
| Trace | Hover a rendered road or building outline, click to adopt it. No freehand, no per-vertex handles. |
| Line | Two-click segment. Hover ends to resize. |
| Arrow / bidirectional | Line plus SVG heads. Size from the list, or setStyle({ strokeWidth }) — widens the shaft and the heads. |
| Circle | Drag to create. Hover for a resize handle. |
| Rectangle | Drag to create. Hover vertices to resize, rotate handle to turn. |
| Polygon | Click vertices, Finish to close. Rotate handle turns it. |
| Measure | Geodesic length, optional terrain samples. |
| Marker | Labeled map pin. Drag the pin to move it. |
| Text | Click 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. |
| Finish | Commit 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.
| Export | Role |
|---|---|
/core | Session, hooks, types, utils, toolbar, list — no Annotate. |
AnnotateProvider | Session. Optional annotations / onChange / fonts. |
Annotate | Map child. Drawing, hover handles, layers. |
AnnotateToolbar | Stock icon toolbar — optional. |
AnnotateList | Stock 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. |
AnnotateToolIcon | Bundled tool SVG. |
Types ship with the package: Annotation, AnnotateTool, AnnotateSession,
and the rest.
CI runs npm run check (typecheck, lint, Prettier, Vitest) on Node 20 and 22.
Tests are jsdom unit tests, not live map tiles.
| Package | Peer floor | Tested in this repo |
|---|---|---|
react / react-dom | ≥ 18 | 19.2 |
react-map-gl | ≥ 8 | 8.1 |
mapbox-gl | ≥ 3 | 3.29 |
maplibre-gl | ≥ 4 | 5.24 |
@vis.gl/react-google-maps | ≥ 1 | 1.10 |
leaflet | ≥ 1.9 | 1.9.4 |
react-leaflet | ≥ 4 | 5.0 |
@arcgis/core | ≥ 4.28 | peer only |
React 18 and react-leaflet 4 stay in range. ArcGIS is an optional peer and is not installed in the default CI graph.
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.
21 commits
TypeScript
97.5%
CSS
2.3%
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
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.
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.

Your buttons call setTool("polygon") and finish(). The map draws. onChange
gives you the same Annotation[] you would save to a database.
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";
| Map | Also install |
|---|---|
| Mapbox | react-map-gl ≥ 8, mapbox-gl ≥ 3 |
| MapLibre | react-map-gl ≥ 8, maplibre-gl ≥ 4 |
@vis.gl/react-google-maps ≥ 1 | |
| Leaflet | leaflet ≥ 1.9, react-leaflet ≥ 4 (v5 on React 19) |
| ArcGIS | @arcgis/core ≥ 4.28 |
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.
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/.
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 library | Terra Draw | Mapbox GL Draw | Leaflet.Draw | Google Drawing Manager | |
|---|---|---|---|---|---|
| React state ownership | Annotation[] 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 APIs | Headless 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 engines | Mapbox, MapLibre, Google, Leaflet, ArcGIS | Mapbox, MapLibre, Google, Leaflet, OpenLayers | Mapbox (MapLibre via community ports) | Leaflet | Google Maps |
| Built-in editing | Move, vertex drag, rotate, mid-edge insert, vertex delete, undo / redo | Select mode (drag, scale, rotate) plus undo / redo | simple_select / direct_select | Edit / delete handlers | Limited after the shape is placed |
| Measurement | Geodesic path, 10 m samples, optional terrain elevation | Not 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.
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.
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.
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.
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:
| Engine | Annotate import | Example |
|---|---|---|
| Mapbox | @orange-groove/react-map-annotate or /mapbox | examples/mapbox.tsx |
| MapLibre | /maplibre | examples/maplibre.tsx |
/google | examples/google.tsx | |
| Leaflet | /leaflet | examples/leaflet.tsx |
| ArcGIS | /arcgis | examples/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.
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.
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.
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 });
Pick a tool. Draw. Press Finish, Enter, or Escape to commit.
<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.
| Tool | What it does |
|---|---|
| Select | Click to select. Shift/⌘-click for more than one. Drag a dotted box to select several at once. |
| Freehand | Sketch a path. Hover for a bounds box; drag to move. Corner handle resizes; rotate handle turns it. |
| Trace | Hover a rendered road or building outline, click to adopt it. No freehand, no per-vertex handles. |
| Line | Two-click segment. Hover ends to resize. |
| Arrow / bidirectional | Line plus SVG heads. Size from the list, or setStyle({ strokeWidth }) — widens the shaft and the heads. |
| Circle | Drag to create. Hover for a resize handle. |
| Rectangle | Drag to create. Hover vertices to resize, rotate handle to turn. |
| Polygon | Click vertices, Finish to close. Rotate handle turns it. |
| Measure | Geodesic length, optional terrain samples. |
| Marker | Labeled map pin. Drag the pin to move it. |
| Text | Click 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. |
| Finish | Commit 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.
| Export | Role |
|---|---|
/core | Session, hooks, types, utils, toolbar, list — no Annotate. |
AnnotateProvider | Session. Optional annotations / onChange / fonts. |
Annotate | Map child. Drawing, hover handles, layers. |
AnnotateToolbar | Stock icon toolbar — optional. |
AnnotateList | Stock 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. |
AnnotateToolIcon | Bundled tool SVG. |
Types ship with the package: Annotation, AnnotateTool, AnnotateSession,
and the rest.
CI runs npm run check (typecheck, lint, Prettier, Vitest) on Node 20 and 22.
Tests are jsdom unit tests, not live map tiles.
| Package | Peer floor | Tested in this repo |
|---|---|---|
react / react-dom | ≥ 18 | 19.2 |
react-map-gl | ≥ 8 | 8.1 |
mapbox-gl | ≥ 3 | 3.29 |
maplibre-gl | ≥ 4 | 5.24 |
@vis.gl/react-google-maps | ≥ 1 | 1.10 |
leaflet | ≥ 1.9 | 1.9.4 |
react-leaflet | ≥ 4 | 5.0 |
@arcgis/core | ≥ 4.28 | peer only |
React 18 and react-leaflet 4 stay in range. ArcGIS is an optional peer and is not installed in the default CI graph.
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.
21 commits
TypeScript
97.5%
CSS
2.3%