How to build an indoor map with Mapbox GL JS
Build a Mapbox GL JS indoor map: extrude rooms and walls, switch floors with filters, add live data, know the indoor API status and avoid occlusion pitfalls.


Building a Mapbox GL JS indoor map
A Mapbox GL JS indoor map is mostly made of three things the library already does well: GeoJSON sources, `fill-extrusion` layers for walls and rooms, and filters that decide which floor is visible. This guide walks through each with short code samples, then covers the pitfalls that take the most time — floors, live data and markers that draw through walls.
Spatial Map is an indoor mapping platform built on Mapbox GL JS, and its whole app is available as MIT-licensed open source. The patterns below come from building it; the last section covers when to build your own and when to start from that code instead.
Start with the data model
Everything is easier if every feature carries the same few properties: a stable `id`, a `kind` (room, wall, door, rack, stair), a `level`, a `name` and a `height`. Coordinates are longitude and latitude, so the building has to be georeferenced first — usually by tracing over a floor plan placed on the map.
{
"type": "Feature",
"properties": {
"id": "L2-214",
"kind": "room",
"level": 2,
"name": "2.14 Meeting",
"height": 0.15
},
"geometry": {
"type": "Polygon",
"coordinates": [[[-0.12801, 51.50702], [-0.12786, 51.50702],
[-0.12786, 51.50711], [-0.12801, 51.50711],
[-0.12801, 51.50702]]]
}
}Walls need to be polygons, not lines: `fill-extrusion` only extrudes areas. Buffer each wall centre-line by half its thickness (Turf’s `buffer` works, though it rounds the ends), or offset both sides yourself for square corners. Doors are easiest as gaps in the wall polygons plus a separate feature for the door leaf.
Extrude rooms and walls
Two layers on one source give you a recognisable 3D building. Floor slabs (rooms) get a thin height so they read as surfaces; walls get their real height. Expressions do the stacking:
const STOREY = 4; // metres from one floor level to the next
map.on('load', () => {
map.addSource('indoor', {
type: 'geojson',
data: '/data/building.geojson',
promoteId: 'id', // lets feature-state address rooms by their own ID
});
// Each floor is lifted by level * STOREY, so the building stacks in 3D.
const base = ['*', ['get', 'level'], STOREY];
map.addLayer({
id: 'rooms',
type: 'fill-extrusion',
source: 'indoor',
filter: ['==', ['get', 'kind'], 'room'],
paint: {
'fill-extrusion-color': ['coalesce', ['feature-state', 'color'], '#d4d4d8'],
'fill-extrusion-base': base,
'fill-extrusion-height': ['+', base, ['get', 'height']],
},
});
map.addLayer({
id: 'walls',
type: 'fill-extrusion',
source: 'indoor',
filter: ['==', ['get', 'kind'], 'wall'], // walls are thin polygons, not lines
paint: {
'fill-extrusion-color': '#a1a1aa',
'fill-extrusion-base': base,
'fill-extrusion-height': ['+', base, ['get', 'height']],
},
});
});Floors as filters
Most indoor maps need two views: all floors stacked (for an overview in 3D) and one floor at a time (for reading it). The second is a filter per layer plus a small floor switcher in your UI:
const LAYER_KIND: Record<string, string> = { rooms: 'room', walls: 'wall' };
function showLevel(level: number) {
for (const [layerId, kind] of Object.entries(LAYER_KIND)) {
map.setFilter(layerId, [
'all',
['==', ['get', 'kind'], kind],
['==', ['get', 'level'], level],
]);
}
}
showLevel(2);If you want the other floors faded rather than hidden, note that `fill-extrusion-opacity` is one value for the whole layer — it cannot vary by feature. Fading “every floor except this one” therefore means one layer per floor (or per opacity step), each with its own filter and opacity.
What about Mapbox’s own indoor support?
Mapbox documents indoor mapping in its Standard style, including an example that adds your own indoor data. At the time of writing, docs.mapbox.com labels it clearly: “Indoor mapping is an experimental feature. APIs may change in future releases.” The example uses an inline style import whose indoor metadata source defines the floor hierarchy, layers filtered with an `is-active-floor` expression, and an `IndoorControl` that draws a floor selector for you.
| Approach | Good for | Trade-off |
|---|---|---|
| Your own sources and `fill-extrusion` layers | Production apps, any style, full control of floors and data | You write the floor switcher and stacking yourself |
| Experimental indoor in the Standard style | Prototypes that want a built-in floor selector | APIs may change; tied to the Standard style; check the docs before relying on it |
Put live data on it
Live data comes in two shapes. States of fixed things (a room’s temperature, a bin’s stock) are best as feature-state on the shapes you already have. Moving things (tags, vehicles, people counts at points) belong in their own source that you replace on a timer or a socket message.
// Recolour a room from live data: no geometry is re-sent.
map.setFeatureState({ source: 'indoor', id: 'L2-214' }, { color: '#ef4444' });
// Moving things (tags, vehicles) live in their own source that you replace.
map.addSource('tags', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
id: 'tags',
type: 'circle',
source: 'tags',
paint: { 'circle-radius': 5, 'circle-color': '#2563eb' },
});
setInterval(async () => {
const res = await fetch('/api/positions'); // your RTLS or middleware
(map.getSource('tags') as mapboxgl.GeoJSONSource).setData(await res.json());
}, 5000);Two details bite here. Feature-state needs feature IDs (`promoteId` or numeric `id`s), and replacing a GeoJSON source’s data with `setData` clears the state set on it — so keep states on the static source and movement on its own. And a `circle` layer always sits on the ground: fine for one floor, wrong for a tag on level 3 of a stacked building.
Pitfalls worth knowing in advance
- Markers draw through walls. `mapboxgl.Marker` is a DOM element above the WebGL canvas, so it never takes part in the depth test; its `occludedOpacity` only reacts to terrain. Our fix is in marker occlusion behind 3D buildings.
- Basemap buildings overlap yours. The basemap’s own 3D buildings draw through your interior. In classic styles, hide or filter the building extrusion layer; in Standard, use its config (for example `show3dObjects`) or the indoor footprint clipping described in the docs.
- Coplanar surfaces flicker. A label, a slab and a floor-plan image at exactly the same height z-fight. Give slabs a few centimetres of thickness and lift what sits on them.
- Floor-plan images are corner-based. An `image` source takes four corner coordinates, so rotating a plan means recomputing the corners. Keep images at 4096 px or less on the long edge; larger textures fail on some devices.
- Clicks hit every floor. `queryRenderedFeatures` returns whatever is drawn under the cursor, including shapes on floors you have faded. Filter the result by the active level before you open a details panel.
- Labels pile up. Room names on stacked floors collide in a tilted view. Give each floor its own symbol layer and show labels only for the active one, or only at higher zoom.
- Big buildings, big sources. One GeoJSON source per building is fine for most sites. For very large campuses, split sources per floor or move static geometry to vector tiles, and keep the frequently changing data small.
- Metres are not Mercator units. If you build geometry in local metres, convert it at the building’s own latitude (`MercatorCoordinate.fromLngLat(...).meterInMercatorCoordinateUnits()` gives the factor), or sizes come out wrong away from the equator.


Build your own, or start from Spatial Map?
A viewer for one building with a floor switcher and a few live layers is a reasonable project on raw Mapbox GL JS. The cost grows with what comes next: an editor so non-developers can change the map, PDF and DWG import, racks with bin addresses, doors that cut walls, multi-floor routing, occlusion, permissions, share links and reports.
| Build your own when… | Start from Spatial Map when… |
|---|---|
| The map is a small feature inside a larger product | The map is the product, or the main screen of an internal tool |
| Your data model is unusual and fixed | You need an editor, floors, racks, doors and routing as they are |
| You will not need non-developers to edit it | Operations or facilities teams will maintain the map |
| You want no dependencies beyond the map library | You want a working base under MIT you can fork and change |
The open-source edition is the full Spatial Map app — React and TypeScript on Mapbox GL JS, a Go API and PostgreSQL with PostGIS — started with Docker Compose and a free Mapbox token. Its ARCHITECTURE.md and docs folder describe how floors, overlays and occlusion are wired. If you would rather not run it at all, the hosted 3D indoor map is the same app.
Try the platform your data belongs on.
Spatial Map turns spreadsheets, sensor streams, and CAD drawings into one live 3D facility view — nothing to install, seven-day free trial.
Keep reading
- TutorialBLE gateway for asset tracking: placement, backhaul, MQTT and the mapGateways are where BLE tracking succeeds or fails. How to place them, connect them, turn their MQTT or HTTP messages into zones, and write the result to a live floor plan.
- TutorialHow to convert a PDF floor plan to a 3D map of a commercial buildingA PDF knows its paper size but not its scale. How to get the scale right, clean the sheet, align it to the site and turn walls, racks and floors into a true-scale 3D building.
- TutorialHow to digitize a paper floor plan: scan, scale, traceFrom an old blueprint or a photo of an evacuation plan to a usable digital plan: capture, perspective correction, scale, the CAD question, and the step to an indoor map.