Mapbox-GL-Draw on top of MapLibre: when you want drawing without paying for Mapbox
title: "Mapbox-GL-Draw on top of MapLibre: when you want drawing without paying for Mapbox" slug: mapbox-draw-on-maplibre pillar: Engineering Quality angle: framework audience: CTOs, senior engineers, fellow practitioners sourceRepo: atkins-web stack: Next.js status: draft
Mapbox-GL-Draw on top of MapLibre: when you want drawing without paying for Mapbox
Two libraries from different vendors talking through a shared canvas. The thin line of glue code that decides whether they cooperate or fight over the map state.
The decision this framework is for
You picked MapLibre because you do not want a Mapbox token tied to a billing account, and MapLibre is a hard fork of mapbox-gl-js from before the license changed. Then someone on the team needs polygon drawing — area selection, region-of-interest, that kind of thing. Writing a polygon editor with snapping, vertex handles, and mid-point insertion is two engineer-months you do not have. The library you want is @mapbox/mapbox-gl-draw, which still works against MapLibre at runtime because MapLibre exposes a compatible enough surface.
This post is the framework that decides when that shortcut is honest, and how to wire it so the seam between the two libraries does not poison the rest of the map. The artifact is components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/index.tsx in an internal urban-analytics Next.js 15 app (atkins-web). It uses @mapbox/mapbox-gl-draw@^1.5.0 against maplibre-gl@^5.6.2. No Mapbox token. No SDK. The draw control attaches, polygons get drawn, turf.booleanIntersects does the hex intersection downstream. The cost is one ugly cleanup path that the post will spend time on, because that path is the whole reason most teams give up on this combination.
The framework
I call it the adopt-tear-down loop, and it has four steps.
- Adopt — push the foreign control into the map only at the moment the user asks for it, never on mount.
- Bound the modes — pick the smallest mode set you can ship with (
draw_polygon,simple_select) and disable the rest at construction time. - Bridge events — translate the foreign event vocabulary (
draw.create,draw.modechange,draw.delete) into your app's own state, immediately. - Tear down — remove the control, the layers it injected, and the sources it injected. By id. Manually. Every single time.
The order matters. If you skip step 4, MapLibre and mapbox-gl-draw will accumulate stale gl-draw-* layers, your z-order will drift, and the next call to addControl will throw because a layer with the same id is already on the map. That failure mode is the reason teams blame "MapLibre incompatibility" when the real bug is that they treated the draw control as a long-lived singleton instead of a disposable.
Adopt: lazy, ref-held, gated on a user gesture
The draw control is heavy. It injects two GeoJSON sources (mapbox-gl-draw-cold, mapbox-gl-draw-hot) and roughly a dozen layers. You do not want any of that on mount. In LayersPanel, the control lives in a ref and is created only when the user clicks the draw button:
const drawRef = useRef<MapboxDraw | null>(null)
const intersectedLayerId = "intersected_hex_layer"
const handleDrawClick = () => {
if (!globalMap) return;
const map = globalMap as any
// remove layer and source if exist
if (map.getLayer(intersectedLayerId)) {
map.removeLayer(intersectedLayerId);
map.removeSource(intersectedLayerId);
}
// Remove existing draw control and listeners
if (drawRef.current) {
try {
map.removeControl(drawRef.current);
} catch (err) {
console.warn('Error removing draw control:', err);
}
drawRef.current = null;
}
Two things are happening here before any drawing starts. First, the downstream layer that the previous draw session produced (intersected_hex_layer) is torn down — the user clicking "draw again" implicitly invalidates the previous result. Second, any previous draw control on the ref is removed defensively. The cast to any is honest: the globalMap from the Zustand store is typed as maplibregl.Map, and removeControl accepts a Mapbox-shaped control which does not perfectly match the MapLibre type signature. The cast is the seam.
globalMap is held in a Zustand store (@/components/viewer/store, see the useViewerStore import at the top of the file) along with isDrawingMode and setIsDrawingMode. The drawing mode flag is what the rest of the viewer reads — popups suppress, the bottom-center panel changes affordances, info boxes close. The draw control itself never reads or writes that flag. The bridge is one-way: foreign events come in, app state goes out.
Bound the modes: tell the library what not to ship
The constructor for MapboxDraw is where you cut down the surface area. Default MapboxDraw ships a toolbar with polygon, point, line, and trash buttons. We do not want that toolbar — the app has its own UI. We also do not want point or line; the only thing this draw session is allowed to produce is a polygon used for spatial intersection.
const draw = new MapboxDraw({
displayControlsDefault: false,
controls: {
polygon: true,
point: false,
line_string: false,
trash: true
},
styles: getFixedDrawStyles()
});
map.addControl(draw, 'top-right');
drawRef.current = draw;
// Change cursor to indicate drawing mode
draw.changeMode('draw_polygon');
(map.getCanvas() as HTMLCanvasElement).style.cursor = 'crosshair';
displayControlsDefault: false is the killswitch — it disables the entire built-in toolbar and you opt back into individual buttons. polygon: true and trash: true are the only ones we want. Then, instead of letting the user open the polygon tool from the (now-hidden) toolbar, draw.changeMode('draw_polygon') is called immediately. The user is already drawing when the control attaches. The cursor change to crosshair is the only visible affordance.
getFixedDrawStyles() is the load-bearing function. It exists because mapbox-gl-draw's default style set uses line-dasharray paint properties, and the MapLibre style parser is stricter than Mapbox's on that property. Empty arrays or invalid values throw. The fix is to read the library's defaults and rewrite the dash arrays to a safe baseline:
const getFixedDrawStyles = () => {
const draw = new MapboxDraw();
const styles = draw.options.styles || [];
return styles.map((style: any) => {
if (style.paint && style.paint['line-dasharray']) {
style.paint['line-dasharray'] = [0, 0]; // Safe default
}
return style;
});
};
The throwaway MapboxDraw instance is the cheapest way to read the library's bundled styles without depending on an internal export. Yes, this is a hack. The function is named honestly. It is also the only patch needed to make a Mapbox-licensed UI library render under a Mapbox-fork map engine.
Bridge events: foreign vocabulary out, app state in
mapbox-gl-draw does not call your React state. It dispatches events on the map: draw.create, draw.update, draw.delete, draw.modechange, draw.selectionchange. The bridge step is where you decide which of those matter and translate them to one of three things — app state change, downstream computation, or teardown.
In atkins-web, exactly one of those events runs analysis. draw.create fires when the user closes the polygon (double-click or Enter). The handler grabs the new feature and pushes it into a turf.booleanIntersects call against the active thematic hex layer:
map.on('draw.create', () => {
(map.getCanvas() as HTMLCanvasElement).style.cursor = '';
setIsDrawingMode(false)
try { map.removeControl(draw) } catch {}
window.removeEventListener('keydown', handleKeyDown)
});
map.on('draw.create', (e: any) => {
console.log('Shape created:', e.features);
getHexIntersection(e.features[0]);
});
Two listeners on the same event. The first one is the lifecycle handler — reset cursor, flip the global drawing flag off, remove the control, unbind the keyboard listener. The second one is the domain handler — take the polygon, ship it to getHexIntersection, which queries rendered features from the 3level-thematic hex layer and runs turf.booleanIntersects per hex. The separation reads as "draw lifecycle" vs "draw payload," and that split is what lets the lifecycle handler be replaced without touching the analysis logic.
The keyboard handler is the other side of the bridge. Enter commits the polygon by switching to simple_select; Escape trashes the in-flight polygon. Both are MapboxDraw's own modes, fired through draw.changeMode() and draw.trash():
const handleKeyDown = (ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
try { draw.changeMode('simple_select') } catch {}
} else if (ev.key === 'Escape') {
try { draw.trash() } catch {}
try { draw.changeMode('simple_select') } catch {}
}
}
window.addEventListener('keydown', handleKeyDown)
The try/catch wraps are not paranoia — they are the seam again. draw.changeMode can throw if the control has been removed between the keypress and the listener firing, which happens on draw.create because the control is removed in the same tick. Swallowing the error is the right choice here because the listener has done its job: the polygon is committed.
The third event in the bridge is draw.modechange, which is the user's exit ramp:
map.on('draw.modechange', (e: any) => {
try {
const mode = e?.mode
if (mode && mode !== 'draw_polygon') {
(map.getCanvas() as HTMLCanvasElement).style.cursor = ''
setIsDrawingMode(false)
}
} catch {}
})
If the user clicks anywhere that switches the mode off draw_polygon, the global drawing flag flips off and the cursor resets. The control itself stays attached, because the user might switch back to draw mode through the trash workflow, but the app no longer thinks it is in a drawing session. That distinction — control is alive, app session is not — is the cleanest part of the bridge.
Tear down: the part that breaks if you forget it
This is where every "I tried mapbox-gl-draw on MapLibre and it broke" thread ends. The control's own removeControl call does not always succeed in cleaning up everything it added. On a clean Mapbox map it does; on MapLibre, you sometimes get orphan layers and orphan sources surviving across draw sessions, because the layer ids match between the two implementations but the layer specs do not always validate identically when re-added.
The cleanup in handleDrawClick is defensive: even before adding a new draw control, every known gl-draw-* layer id and the two mapbox-gl-draw-* source ids are removed by id:
const drawLayerIds = [
'gl-draw-line',
'gl-draw-polygon-fill',
'gl-draw-polygon-stroke',
'gl-draw-polygon-midpoint',
'gl-draw-polygon-and-line-vertex-halo-active',
'gl-draw-polygon-and-line-vertex-active',
'gl-draw-polygon-and-line-vertex-inactive',
'gl-draw-point-inactive',
'gl-draw-point-active',
'gl-draw-point-outer.hot',
'gl-draw-midpoint.hot',
'gl-draw-midpoint.cold',
'gl-draw-vertex.hot',
'gl-draw-vertex.cold'
];
drawLayerIds.forEach((layerId) => {
if (map.getLayer(layerId)) {
try { map.removeLayer(layerId); } catch (err) {
console.warn(`Could not remove layer ${layerId}:`, err);
}
}
});
// Remove draw sources
['mapbox-gl-draw-cold', 'mapbox-gl-draw-hot'].forEach((sourceId) => {
if (map.getSource(sourceId)) {
try { map.removeSource(sourceId); } catch (err) {
console.warn(`Could not remove source ${sourceId}:`, err);
}
}
});
This is the ugliest block in the file and the one I would not let a junior engineer skip. The id list is hard-coded from the library's source — every layer the draw control creates is enumerated. If a future mapbox-gl-draw minor version adds a new internal layer id, this list will silently miss it and the leak comes back. The mitigation is to pin the library version in package.json (^1.5.0 here) and review the diff before bumping. A test that asserts the map has zero gl-draw-* layers after a teardown would catch the leak; the repo does not have that test yet, and that is a fair criticism.
The draw.delete handler closes the loop — if the user trashes the polygon mid-draw, the same teardown runs, the keyboard listener unbinds, the cursor resets:
map.on('draw.delete', () => {
setIsDrawingMode(false)
try { map.removeControl(draw) } catch {}
;(map.getCanvas() as HTMLCanvasElement).style.cursor = ''
window.removeEventListener('keydown', handleKeyDown)
})
Note the try/catch again. By the time draw.delete fires, the control may already be gone from a parallel path. The handler tolerates that.
Where the framework fails
The framework breaks down in three places.
Multiple concurrent draws. The whole design assumes one draw session at a time, gated on a single ref. If the product needs two draw surfaces simultaneously — for example, draw an exclusion polygon while keeping the inclusion polygon visible — you need a different abstraction. The repo has a separate DrawingToolComponent.ts under components/viewer/components/ that implements a custom drawing engine for points, lines, rectangles, and circles backed by per-target feature stores. That custom engine exists precisely because mapbox-gl-draw did not scale to the multi-layer, multi-shape drawing the rest of the viewer needs. The framework in this post covers polygon-for-analysis only.
Style updates after attach. mapbox-gl-draw reads its styles at construction. If the host map switches base styles after the control is attached, the draw layers can survive the style switch in an inconsistent state. The cleanup-then-recreate approach hides this — every new draw session is a fresh control — but if you tried to leave the control attached across base-style changes, you would lose the draw layers and have to re-add them by hand.
MapLibre API drift. Today the surfaces line up. map.addControl, map.removeControl, map.getCanvas, map.getLayer, map.getSource, map.on('draw.*') — all of it works because MapLibre kept the contract. Nothing in the MapLibre roadmap promises it will keep working. The day MapLibre changes one signature, this code is on you to fix. The any casts in the file are honest about that.
Trade-off
You save the cost of writing a polygon editor and you pay the cost of owning a sharp seam between two libraries that no longer share a roadmap. The seam is small — one file, roughly 150 lines, mostly cleanup. It is not zero. The judgment call is whether the polygon editor is the rare part of your product worth owning end-to-end, or whether it is one feature among many and you would rather spend the time on the analytics that the polygon enables. For atkins-web, the analytics is the product. The drawing is the input. Renting the input was the right call.
Business impact
This is the difference between shipping the spatial-intersection feature in a sprint versus a quarter. The audience for this app — urban analysts, planners, policy teams — does not care that a draw control was rented from a different vendor. They care that they can outline an area, hit Enter, and see which hexagons fall inside. The framework above is what makes "rented" not look rented in the UX. If you are quoting this kind of feature to a client, the line item is "polygon drawing surface, 2 days, includes teardown and analysis bridge," not "build a polygon editor, 4 weeks." The numbers are not equivalent. Neither is the risk profile.
What to do next
If you have a MapLibre app and someone has asked for polygon drawing, open your codebase and search for mapbox-gl-draw. If you find it, find the teardown. If the teardown is not enumerating layer ids and source ids by name, the bug is already in your repo and you have not seen it yet because no user has clicked "draw" twice in the same session against the same base style. Add the enumeration. Add a console assertion in dev mode that there are zero gl-draw-* layers after teardown. Then go back to building the analytics.
{
"title": "Mapbox-GL-Draw on MapLibre: drawing without a Mapbox token",
"metaDescription": "How to wire @mapbox/mapbox-gl-draw against MapLibre for polygon drawing — the adopt, bound modes, bridge events, tear-down loop, with the cleanup that everyone forgets.",
"slug": "mapbox-draw-on-maplibre",
"canonical": null,
"primaryKeyword": "mapbox-gl-draw maplibre",
"secondaryKeywords": [
"mapbox-gl-draw",
"maplibre-gl",
"draw_polygon",
"gl-draw teardown",
"polygon drawing react",
"next.js maplibre",
"turf booleanIntersects"
],
"audience": "CTOs, senior engineers, fellow practitioners",
"searchIntent": "how-to",
"internalLinkTargets": ["/services", "/case-studies"],
"schema": {
"type": "BlogPosting",
"faq": [
{
"question": "Does @mapbox/mapbox-gl-draw work on MapLibre?",
"answer": "Yes, at runtime — MapLibre kept the relevant surface from the pre-fork mapbox-gl-js. You patch the default styles to remove invalid line-dasharray values, attach with map.addControl, and clean up the gl-draw-* layers and mapbox-gl-draw-* sources on teardown."
},
{
"question": "Why do I get leftover gl-draw-* layers after closing a draw session?",
"answer": "Because MapLibre does not always remove every layer the draw control added when removeControl is called. The fix is to enumerate every gl-draw-* layer id and every mapbox-gl-draw-* source id and remove them by name before attaching a new draw control."
},
{
"question": "Can I run two draw controls at once?",
"answer": "Not with this framework. The single-ref, single-session pattern in this post covers polygon-for-analysis only. For multi-shape, multi-layer drawing, write a custom drawing engine that owns its own feature stores per target."
},
{
"question": "Do I need a Mapbox account or token?",
"answer": "No. mapbox-gl-draw is a UI library that does not call Mapbox services; it only manipulates a GL map. MapLibre provides that map without any Mapbox dependency."
}
]
},
"coverImagePrompt": "two stacked semi-transparent map panels on a dark navy background, one panel labeled M, one panel labeled D, a thin coral polygon outline drawn through both panels with visible vertex handles at each corner",
"citation": {
"rule": "SEO_and_AEO_Rules/blog-seo-checklist.md",
"cluster": "SEO_and_AEO_Rules/search-intent-and-topic-clusters.md"
},
"notes": "Body draft complete. Grounded in components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/index.tsx. Six code blocks plus the draw-layer id enumeration."
}
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox