Mapbox recently added indoor data to its public map styles. Zoom into one of 200+ mapped airports in the Mapbox Standard style, flip on a single configuration flag, and you get floor plans, a floor selector, and a smooth transition from the street into the terminal:
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/standard',
config: { basemap: { showIndoor: true } }
});
map.addControl(new mapboxgl.IndoorControl());
That's the whole integration. Coverage is expanding, and more venue types are coming.
But there's a decent chance the building you actually care about - your office, a campus, a hospital, a stadium, a transit hub - isn't in that dataset and won't be for a while. So can you run the same machinery on your own floorplans?
Yes - and you don't even need to publish a tileset to do it. The indoor system in GL JS is not hard-wired to Mapbox's tilesets, or to any tilesets at all. Which data drives it is a configuration in the style, and that config is happy to point at your custom data. Change the config and the whole apparatus - building detection, floor state, the floor switcher UI, per-floor filtering - runs on your data instead.
This post assumes you already have the two things that actually take effort to produce: a building footprint and a set of floorplan shapes (rooms, corridors, whatever you've got) for each floor. From there it's a straight line: derive the metadata that makes floor switching work, drop it next to your other GeoJSON files, and wire up the style.
Heads up: indoor support in SDKs is marked experimental, and the style property this post relies on isn't in the public style specification yet. It works today (this was written against GL JS v3.28.1), but treat it as something that can change between minor versions, and pin your SDK version if you ship it.
The part that isn't obvious
Before touching data, it's worth understanding how Mapbox SDK decides what "the current floor" means, because every design decision downstream follows from it.
There are two kinds of layers in an indoor tileset, and they do completely different jobs.
Metadata layers describe buildings and floors. Their geometries are never rendered - they're just coverages that are scanned to build a dependency graph: which buildings exist, where their centers are, which floors belong to which building, which floors can be shown together, and which one gets shown by default. This graph populates the floor selector and tracks the active floor as the user pans.
Geometry layers are the things you actually see on the map - structures, floors, floorplans, doors, labels etc. They get filtered by an expression that asks "is this feature's floor currently active?":
["is-active-floor", ["get", "floor_id"]]
Metadata layers are the state machine, geometry layers are the view. Once that clicks, the rest of the work is mostly bookkeeping - making sure the identifiers in your view layers line up with the graph in your state layers.
What we're building
A sample map of a single three-storey building with a working floor switcher, rendered on top of Mapbox Standard, reading straight from our own indoor data. Everything scales from there: more floors, more buildings - up to a whole campus.
Step 1: Define the schema
Mapbox's indoor tileset is a useful template for property names even though we're not publishing a tileset here - it's exactly what GL JS's parser expects either way. The Indoor v3 tileset reference documents the full schema; you don't need all of it, we’ll start with just a few files:
| File | Geometry | What it holds |
|---|---|---|
| indoor-structure.geojson | Polygon | Building footprints |
| indoor-floorplan.geojson | Polygon | Rooms, amenities, corridors |
| indoor-door.geojson | Line | Doors and openings |
| indoor-pois.geojson | Point | Points of interest |
| indoor-metadata.geojson | Polygon | Building and floor graph together |
The hierarchy is structure → floor → floorplan/door/poi, and each level points at its parent by ID: floor metadata features carry structure_ids, everything on a floor carries floor_id.
The properties that are actually mandatory
Most of the schema is optional and only affects styling. These are the ones GL JS will reject a feature over.
A structure metadata feature needs:
{
"type": "structure",
"id": "bldg1",
"name": "Sample building",
"center_lon": 22.56002,
"center_lat": 62.136234
}
type must be the literal string "structure". center_lon/center_lat should longitude and latitude of a point within the building - GL JS uses them to find the closest building to the viewport.
A floor metadata feature needs:
{
"type": "floor",
"id": "fl1",
"name": "1",
"z_index": 0,
"structure_ids": "bldg1",
"is_default": true,
"conflicted_floor_ids": "fl2;fl3"
}
type must be "floor", and id, name and z_index must all be present. name is what appears on the floor selector button, so keep it short - "1", "2A", "-1". z_index sorts the floors vertically.
The three _ids fields are semicolon-delimited lists, and they're where most of the interesting behaviour lives:
- structure_ids links a floor to its building. Omit it and the floor is orphaned - it exists, but no building claims it, so it never appears in any selector.
- is_default marks the floor shown when the venue first appears on the map. Set it on exactly one floor per building, usually the lowest non-negative z_index.
- conflicted_floor_ids lists floors that must never be visible at the same time.
- And optionally connected_floor_ids lists floors that should be shown alongside this one - useful when a walkway on level 2 of one building connects to level 3 of another.
conflicted_floor_ids is the one people skip, and skipping it produces a genuinely confusing bug. A floor counts as active if it's the one the user just selected, or the default floor, or the previously active floor - and stays active for all of those unless
conflicted_floor_idssays two of them can't coexist. So within a building, every floor should list every one of its siblings as a conflict.
Step 2: Shape your GeoJSON files
Let’s say we start with a building footprint in indoor_structure.geojson and our rooms referenced to a floor in indoor_floorplan.geojson. Data can originate from whatever mapped the venue - CAD, a survey, or tracing a raster. For small venues geojson.io might be the quickest way to draw and edit properties manually.
The important step is building metadata and there's a shortcut that makes it nearly free: the metadata geometries don't need to be precise per-floor outlines - they're coverage hints, not something that gets rendered. So for a straightforward building, you can generate every metadata feature by duplicating the building footprint and for complex you can use bounding boxes.
For example if you work in geojson.io:
- Duplicate the building footprint feature once and add the structure properties (
type: "structure",id,name,center_lon,center_lat). - Duplicate it again for each floor, adding that floor's properties (
type: "floor",id,name,z_index,structure_ids,is_default,conflicted_floor_ids). - Put all of these features - the one structure feature and every floor feature - into a single GeoJSON FeatureCollection.
In our case for a single building that's one structure feature:
| id | name | type | center_lon | center_lat |
|---|---|---|---|---|
| bldg1 | Sample building | structure | 22.56002 | 62.136234 |
...and one floor feature per storey, all sharing the building's footprint geometry:
| id | name | type | z_index | structure_ids | is_default | conflicted_floor_ids |
|---|---|---|---|---|---|---|
| fl1 | 1 | floor | 0 | bldg1 | true | fl2;fl3 |
| fl2 | 2 | floor | 1 | bldg1 | false | fl1;fl3 |
| fl3 | 3 | floor | 2 | bldg1 | false | fl1;fl2 |
That's the entire floor graph all in just one file. See the indoor_metadata object in the codepen for the complete sample.
If your floors genuinely have different footprints from the building outline (a stepped tower, a floor that's smaller than the ones below it), use the real per-floor outline instead of the duplicated building shape - the mandatory properties don't change either way.
The layers that actually render need real geometry and reference their floor: it's quite easy to add data for rooms and corridors, door lines and point labels in geojson.io. You can find sample data in the codepen constants.
Step 3: Write the style
This is where it comes together. We’ll add a few sources that point at our files:
sources: {
'indoor-metadata': { type: 'geojson', data: 'indoor_metadata.geojson' },
'indoor-structure': { type: 'geojson', data: 'indoor_structure.geojson' },
'indoor-floorplan': { type: 'geojson', data: 'indoor_floorplan.geojson' },
'indoor-doors': { type: 'geojson', data: 'indoor_door.geojson' }
'indoor-pois': { type: 'geojson', data: 'indoor_pois.geojson' }
}
The piece that makes it all work is the indoor config block. We point it at our metadata source.
indoor: {
venue: {
sourceId: 'indoor-metadata',
sourceLayers: []
}
}
Empty sourceLayers tells the engine there are no named source-layers to look for (there aren't any in a GeoJSON source) and to scan whatever single layer the source produces instead. If we were to use the tileset, we would need to list the layers that contain metadata features.
That block goes inside a second, inline style import stacked on top of Mapbox Standard. Alongside it, style layers are filtered by is-active-floor expression. The structure also gets a clip layer to clear the path for displaying our data on the map with no overlapping features on top. Rooms doors and labels are just normal fill/line/symbol layers.
One easy-to-miss detail: indoor mode only turns on once some visible layer reads from the metadata source. The sample below adds a transparent fill layer (
fill-opacity: 0) onindoor-metadatapurely to trigger that - it's not meant to be seen, just present.
Step 4: Turn on the floor selector
const map = new mapboxgl.Map({
container: 'map',
style: style,
center: [22.560020, 62.136234],
zoom: 19.5,
pitch: 50,
bearing: -15
});
map.addControl(new mapboxgl.IndoorControl());
That's it. IndoorControl reads its state from the same graph your metadata features produced. Pan into the building and it populates with your floors, sorted by z_index; click a floor and every layer filtered on is-active-floor updates.
Two notes:
IndoorControl requires GL JS v3.21.0 or newer.
On iOS and Android (Maps SDK v11.19.0+), the equivalent is the indoorSelector ornament/plugin - it ships a ready-made floor selector UI that appears automatically, same as IndoorControl. You get the stock look out of the box and the custom floor-selector UI is on the roadmap.
You do not need showIndoor: true for custom data - that flag turns on Mapbox's own indoor tileset, which is a separate thing. Leave it off unless you want both, and if your features happen to be somewhere Mapbox has already mapped, you should use ids of Mapbox metadata.
The complete working example - all five sources, every layer, and the IndoorControl setup - is in the attached codepen. Feel free to download, drop your own GeoJSON data and serve with any static server.
Troubleshooting checklist
| Symptom | Cause |
|---|---|
| Floor selector never appears | Building isn't detected. Check center_lon/center_lat on the structure metadata feature (missing defaults to 0,0 - the Atlantic) and confirm type is exactly "structure". |
| Selector appears but is empty, or missing floors | Floors are orphaned. Check structure_ids matches the structure's id exactly, that every floor has id, name and z_index
|
| Nothing happens at all, no floor UI, no errors | No visible layer reads from the metadata source yet. Add a layer on it (even a transparent one) - the engine only activates indoor mode once one exists. |
| Two floors render on top of each other when you switch | Missing conflicted_floor_ids. Previously-active and default floors stay visible unless they conflict with the new selection - every floor needs every sibling listed. |
| Two floors collapse into one button | The selector de-duplicates by z_index - gives each floor in a building a distinct value. |
At scale
Everything above works because a handful of GeoJSON files stay small enough to parse client-side on every load. Once you're at dozens of floors, several buildings, or venues that update often enough that hand-maintaining files gets tedious, the same schema publishes just as well as a Mapbox vector tileset - and buys back the usual vector-tile benefits (server-side simplification, caching, not shipping full-precision geometry at every zoom etc).
The workflow is the same conceptually - structure and floor metadata, plus visible features geometry - except a vector tileset supports multiple source-layers per source, so structure and floor metadata can reside in separate files if you'd rather generate them that way. Publishing may be performed with the Tilesets CLI (upload-source per layer, a recipe, create + publish), and scripting can be used for metadata generation.
Where this leaves you
The interesting thing here isn't any individual step - it's that the indoor system was built with the data source as a parameter rather than a constant. Mapbox's indoor tileset is the default, not a requirement, and it doesn't even need to be a tileset - a few GeoJSON files are enough to get building detection, floor state management and a floor selector for free.
This is still an experimental area. IndoorControl is documented but flagged as subject to change, and the indoor style property isn't in the published style specification yet. Pin your SDK version, and expect to revisit this when it graduates.
If you build something with it, we'd like to see it - drop a comment or find us in the Mapbox Developer Discord.
Further reading
- Indoor v3 tileset reference - the full schema
- Indoor mapping in GL JS - the official guide for Mapbox's data
- Mapbox Standard configuration reference - showIndoor, showIndoorLabels, indoor label featuresets
- Tilesets CLI reference - if you want to utilize Mapbox vector tiles for your data
- geojson.io - quick way to draw and property-edit data for a small venue
- Mapbox airport indoor maps - Mapbox indoor data coverage

Top comments (0)