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.
  • queryRenderedFeatures only 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.

A click producing rendered features from the named layer, deduplicated by feature id, with attributes read from the tile.
Only rendered features respond. Anything filtered out or below its minzoom is not clickable.

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.

A building clipped into two tiles returning two hits for one click, deduplicated by feature id.
One feature, two tiles, two hits. An id makes the deduplication reliable.

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;",
              '"': "&quot;", "'": "&#39;" }[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.

Rebuilding a filter on hover against using feature state, which updates one feature without re-evaluating the layer.
Feature state needs an id in the tiles. Without one it accepts the call and does nothing.

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.
  • queryRenderedFeatures respects filters and zoom; querySourceFeatures does 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.

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.