How to Add Interactivity and Popups to a Web Map
Problem statement
Clicking a feature and seeing its attributes is the first thing anyone asks of a web map, and with vector tiles it has four complications that do not exist with a plain GeoJSON layer:
- The attributes must be in the tiles. They were not free β two string fields added 38% to a measured tile.
- Features are clipped and duplicated across tile boundaries, so one building can be two hits.
queryRenderedFeaturesonly sees what is drawn, so anything filtered or off-screen is invisible to it.- Geometry from a tile is clipped and quantised, so measurements taken from it are wrong.
Quick answer
map.on("click", "buildings-fill", (e) => {
const feature = e.features[0];
if (!feature) return;
new maplibregl.Popup({ closeButton: true })
.setLngLat(e.lngLat)
.setHTML(`<strong>${feature.properties.name ?? "Building"}</strong><br>
${feature.properties.building ?? "unknown type"}`)
.addTo(map);
});
map.on("mouseenter", "buildings-fill", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "buildings-fill", () => {
map.getCanvas().style.cursor = "";
});
e.features contains only features from the layers named in the handler, already filtered by the layer's filter and zoom range β which is usually what you want and occasionally not.
Step-by-step solution
1. Include an identifier in the tiles
feature = {"geometry": ..., "properties": {...}, "id": int(row["osm_id"])}
A stable feature id enables three things: deduplicating clipped features, highlighting on hover with feature state, and fetching full detail from an API without embedding it in every tile.
It is the single most valuable property to include, and it costs less than any string attribute.
2. Deduplicate the hits
A feature crossing a tile boundary is clipped into both tiles, so a click near the seam returns it twice.
const seen = new Set();
const unique = e.features.filter((f) => {
const key = f.id ?? JSON.stringify(f.properties);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
Without an id the fallback is hashing the properties, which fails when two genuinely different features share attributes.
3. Use feature state for hover, not a filter
map.setFeatureState({ source: "buildings", sourceLayer: "buildings", id },
{ hover: true });
"fill-opacity": ["case", ["boolean", ["feature-state", "hover"], false], 0.9, 0.5]
Feature state changes a paint property without re-evaluating the whole layer, so it is smooth. Rebuilding a filter on every mouse move is not, and it becomes visibly janky above a few thousand features.
Feature state requires an id in the tiles. Without one it silently does nothing.
4. Fetch detail rather than embedding it
Putting a full address, description and history in every tile multiplies the pyramid. Putting an id and fetching the rest on click costs one request per interaction.
const response = await fetch(`/api/features/${feature.id}`);
That also keeps the detail current without regenerating tiles.
5. Never measure tile geometry
Geometry in a vector tile is clipped at the tile edge and quantised to the tile grid β at extent=4096 and zoom 15 that is about 18 cm. Computing an area or a length from it gives a clipped, snapped answer.
Send the id to a service that has the real geometry.
Code examples
Example 1 β click, hover and highlight together
function addInteractivity(map, { layerId, sourceId, sourceLayer,
titleField = "name", detailUrl = null }) {
let hoveredId = null;
map.on("mousemove", layerId, (e) => {
map.getCanvas().style.cursor = "pointer";
if (!e.features.length) return;
const id = e.features[0].id;
if (id === undefined) return; // no id in the tiles
if (hoveredId !== null) {
map.setFeatureState({ source: sourceId, sourceLayer, id: hoveredId },
{ hover: false });
}
hoveredId = id;
map.setFeatureState({ source: sourceId, sourceLayer, id },
{ hover: true });
});
map.on("mouseleave", layerId, () => {
map.getCanvas().style.cursor = "";
if (hoveredId !== null) {
map.setFeatureState({ source: sourceId, sourceLayer, id: hoveredId },
{ hover: false });
hoveredId = null;
}
});
map.on("click", layerId, async (e) => {
const seen = new Set();
const unique = e.features.filter((f) => {
const key = f.id ?? JSON.stringify(f.properties);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (!unique.length) return;
const feature = unique[0];
const popup = new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(`<strong>${escapeHtml(feature.properties[titleField] ?? "Feature")}</strong>
<div>loadingβ¦</div>`)
.addTo(map);
if (detailUrl && feature.id !== undefined) {
try {
const response = await fetch(`${detailUrl}/${feature.id}`);
const detail = await response.json();
popup.setHTML(renderDetail(detail));
} catch (err) {
popup.setHTML(`<strong>${escapeHtml(feature.properties[titleField] ?? "Feature")}</strong>
<div>details unavailable</div>`);
}
} else {
popup.setHTML(renderProperties(feature.properties));
}
});
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g,
(c) => ({ "&": "&", "<": "<", ">": ">",
'"': """, "'": "'" }[c]));
}
escapeHtml is not optional. Feature properties come from data you may not control, and setHTML will happily execute a <script> tag from an OpenStreetMap name field.
Example 2 β a paint expression that responds to state
{
id: "buildings-fill",
type: "fill",
source: "buildings",
"source-layer": "buildings",
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#ef4444",
["boolean", ["feature-state", "hover"], false], "#14b8a6",
"#0ea5e9",
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "hover"], false], 0.85,
["interpolate", ["linear"], ["zoom"], 12, 0.3, 16, 0.6],
],
},
}
["boolean", ["feature-state", "hover"], false] supplies the default. Without the boolean wrapper, an unset feature state is null and the case produces a type error that MapLibre reports once and then renders the fallback.
Example 3 β the detail endpoint that popups fetch
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/api/features/{feature_id}")
def feature_detail(feature_id: int):
"""Full attributes and real geometry β neither is in the tiles."""
gdf = STATE["data"]
match = gdf[gdf["osm_id"] == feature_id]
if match.empty:
raise HTTPException(404, "feature not found")
row = match.iloc[0]
utm = match.to_crs(match.estimate_utm_crs()).iloc[0]
return {
"id": feature_id,
"properties": {k: (None if pd.isna(v) else v)
for k, v in row.drop("geometry").items()},
"area_m2": round(float(utm.geometry.area), 1),
"perimeter_m": round(float(utm.geometry.length), 1),
"centroid": [round(float(row.geometry.centroid.x), 6),
round(float(row.geometry.centroid.y), 6)],
}
Area and perimeter are computed from the source geometry in a projected CRS. Computing them in the browser from tile geometry would give a clipped, quantised, degree-based answer β wrong three times over.
Explanation
Why queryRenderedFeatures sees only what is drawn
MapLibre answers queries from its render state, not from the tile data. So a feature excluded by the layer's filter, or outside the layer's zoom range, or in a tile that has not loaded, does not appear.
That is usually what you want: clicking should hit what the user can see.
When it is not β searching for a feature currently off-screen, or counting everything in the source β querySourceFeatures reads the loaded tiles directly, ignoring filters and styling. It still only sees loaded tiles, so it is not a substitute for querying the source data.
Why duplicates appear
Each vector tile must render independently, so geometry crossing a boundary is clipped into both tiles. A click near a seam returns one hit from each.
The duplicates have identical properties and different geometry β each is the clipped part in its own tile. Deduplicating by id is exact; deduplicating by properties fails when two real features share attributes, which for buildings is common.
Why feature state exists
The obvious way to highlight on hover is to update a filter or a paint expression referencing the hovered id. Both force MapLibre to re-evaluate the layer, which at thousands of features means re-tessellating geometry on every mouse move.
Feature state is a separate per-feature key-value store that paint expressions can read. Changing it updates only the affected feature's attributes in the GPU buffers.
The requirement is a feature id, which must be in the tiles. Without one, setFeatureState accepts the call and does nothing.
Why to fetch detail rather than embed it
Attributes are stored per tile, so a description repeated across every zoom level is stored at every zoom level. Measured, two short string fields added 38% to a tile.
An id plus a detail endpoint costs one request per click and keeps the tiles small. It also decouples the data lifecycle: attributes change without regenerating the pyramid.
The trade-off is that the map no longer works offline for detail, and each click has a latency the embedded version does not.
Edge cases or notes
- Include a feature id in the tiles. Deduplication and feature state both need it.
- Deduplicate hits; clipped features appear once per tile.
- Escape property values before
setHTML. Data is not trusted input. ["boolean", ["feature-state", ...], false]supplies the default for unset state.queryRenderedFeaturesrespects filters and zoom;querySourceFeaturesdoes not.- Never measure tile geometry β it is clipped and quantised.
- Touch devices need a larger hit area; query a small box rather than a point.
- Popups on mouse move need throttling or they fight the render loop.
Internal links
- Vector tiles explained: MVT, layers and why they are not GeoJSON β why clipping and quantisation happen
- How to style data-driven colours in a web map β expressions and feature state
- How to serve a web map from PMTiles with MapLibre β the map this attaches to
- My web map is blank or the layer never appears β the query API as a diagnostic
- How to publish a GeoJSON API from a GeoDataFrame with FastAPI β the detail endpoint
- How to build vector tiles from a GeoDataFrame in Python β including ids and attributes
- How to create interactive maps in Python with Folium β the simpler alternative
- How to calculate area and distance in GeoPandas (correctly) β why measurement belongs server-side
FAQ
How do I add a popup to a vector tile layer?
Listen for click on the layer id, take e.features[0], and set a popup's HTML from its properties β escaping the values first.
Why does one click return the same feature twice?
Because it crosses a tile boundary and is clipped into both tiles. Deduplicate by feature id.
How do I highlight a feature on hover?
map.setFeatureState with a hover key, and a paint expression reading ["feature-state", "hover"]. It needs a feature id in the tiles.
Why does setFeatureState do nothing?
The features have no id. Ids must be written into the tiles at generation time.
Should I put all my attributes in the tiles?
No. Include an id and what the style needs; fetch the rest on click. Two string fields added 38% to a measured tile.
Can I compute area from a clicked feature?
Not reliably. Tile geometry is clipped at tile edges and quantised to the tile grid. Ask a service that has the source geometry.
Why does my click handler miss features that are visible?
queryRenderedFeatures respects the layer's filter and zoom range. If the feature is drawn by a different layer, name that layer in the handler.