Popups, Tooltips & Feature Interaction

Part of the Core Mapping Architecture & Rendering guide.

A map that cannot be interrogated is a picture. Interaction — hovering a district to see its name, clicking a sensor to read its last value, selecting two regions to compare them — is what turns a rendered layer into a dashboard. It is also where generated maps most often go wrong, because the interaction is written once in Python and then has to behave correctly against data the author never saw: features with missing fields, values containing markup, layers dense enough that hit testing becomes the frame budget, and readers on touch screens where hover does not exist.

Prerequisites

Step 1 — Choose the Affordance per Layer

There are three interaction models and they are not interchangeable. Choosing per layer, rather than applying one pattern to the whole map, is what makes a dashboard feel considered.

Three interaction affordances and where each one fails A hover tooltip carries one short identifying fact, costs a mousemove handler per frame and does not exist on touch devices. A click popup carries anything the reader must read or copy, costs one handler per click and works everywhere. Persistent selection supports comparing several features and costs a state store, but is the only model that survives a page reload when serialised into the URL. Pick per layer, not per dashboard hover tooltip carries: one identifying fact cost: a mousemove hit test good for: orientation while scanning fails on: every touch device never the only route to a value throttle to one test per frame click popup carries: the full record cost: one hit test per click good for: reading, copying, acting works on every input device the default choice for a dashboard close on Escape as well as on click-away persistent selection carries: a set of features cost: a state store good for: comparison, workflows survives a reload when in the URL needs a visible way to clear it pair with a side panel, not a popup

Step 2 — Implement Hit Testing That Stays Cheap

Every renderer already knows which feature is under the cursor, and its answer is faster and more correct than anything you can compute in JavaScript. Leaflet dispatches events from the layer that was hit. MapLibre exposes queryRenderedFeatures scoped to specific layer ids. Deck.gl renders a picking pass in which each object gets a unique colour, so a lookup is a single pixel read regardless of how many million objects exist.

The two mistakes that cost frames are binding a mousemove handler to the whole map and running an unscoped query, and doing point-in-polygon maths on the original GeoJSON. The first tests every layer on every pointer move; the second scales linearly with feature count on the main thread.

// MapLibre: scope the query, throttle to one test per animation frame.
const INTERACTIVE_LAYERS = ["regions-fill", "sensors-circle"];

let queued = false;
let lastEvent = null;

map.on("mousemove", (event) => {
  lastEvent = event;
  if (queued) return;
  queued = true;
  requestAnimationFrame(() => {
    queued = false;
    const features = map.queryRenderedFeatures(lastEvent.point, {
      layers: INTERACTIVE_LAYERS,          // never query every layer
    });
    map.getCanvas().style.cursor = features.length ? "pointer" : "";
    updateTooltip(features[0] ?? null, lastEvent.lngLat);
  });
});

Scoping matters more than throttling. A dashboard with a basemap, three overlays and a label layer will test all of them on every pointer move if you let it, and the label layer is usually the most expensive because its geometry is densest.

Four hit-testing approaches and how they scale GPU picking reads one pixel and costs the same at a thousand or a million features. A scoped rendered-feature query tests only the named layers and scales with what is visible in the tile. An unscoped query tests every layer including labels and basemap. Point-in-polygon maths on the source GeoJSON runs on the main thread and scales linearly with the whole dataset. Cost per pointer move, and how it grows with the dataset GPU picking pass (deck.gl) one pixel read constant — identical at 1 000 and 1 000 000 features scoped queryRenderedFeatures tests the named layers only scales with what is visible in the current tiles — the practical default unscoped query tests basemap and labels too often 5–10× the scoped cost, for answers you discard point-in-polygon on the source GeoJSON linear in dataset size, on the main thread

Step 3 — Render Popup Content Safely

Popup content is generated from attribute values, and attribute values come from upstream systems. Treating them as trusted markup is the most common security defect in generated dashboards: a place name containing a stray angle bracket becomes broken layout, and one containing a script tag becomes an execution path in whatever page embeds the map.

Build popups from a template with an explicit field allow-list, escape every value, and cap the row count so that a record with two hundred columns does not produce a popup taller than the viewport.

from __future__ import annotations

import html
from dataclasses import dataclass


@dataclass(frozen=True)
class PopupSpec:
    """Which fields appear in a popup, in order, with display labels."""

    fields: list[tuple[str, str]]        # (property_name, display_label)
    max_rows: int = 12
    empty_text: str = "—"

    def render(self, properties: dict) -> str:
        rows = []
        for name, label in self.fields[: self.max_rows]:
            raw = properties.get(name)
            value = self.empty_text if raw is None or raw == "" else str(raw)
            rows.append(
                f"<tr><th scope=\"row\">{html.escape(label)}</th>"
                f"<td>{html.escape(value)}</td></tr>"
            )
        return (
            '<table class="feature-popup"><caption class="visually-hidden">'
            "Feature attributes</caption><tbody>"
            + "".join(rows)
            + "</tbody></table>"
        )


POPUP = PopupSpec(
    fields=[
        ("name", "Region"),
        ("incidents_per_1k", "Incidents / 1k"),
        ("population", "Population"),
        ("updated_at", "Last updated"),
    ]
)

An allow-list also solves a subtler problem: it stops internal columns from leaking. Join keys, geometry hashes, staging flags and the updated_by column are all things a pipeline adds and nobody intended to publish, and a popup built by iterating over feature.properties publishes every one of them.

Step 4 — Reach Keyboard and Touch Parity

A <canvas> element exposes nothing to a screen reader, and a WebGL surface exposes less than that. Interaction that exists only as a pointer event over a canvas is interaction that a substantial group of readers cannot use at all. The fix that works without rebuilding the renderer is a parallel DOM representation: a visually compact, focusable list of the features currently in view, where focusing an entry highlights the corresponding feature on the map and activating it opens exactly the same popup content.

Touch needs different work. There is no hover, so any tooltip-only information must also be reachable by tap. Hit targets need to be at least around 44 pixels — enlarge the picking radius rather than the drawn symbol, so the map does not become a field of oversized circles. And a tap that opens a popup should not also pan the map, which means distinguishing a tap from a drag by movement threshold rather than by timing.

Step 5 — Interaction at Scale: Clustering and Aggregated Picking

Interaction design that works for two hundred features stops working at two hundred thousand. At high density, individual symbols overlap so heavily that clicking one is a matter of luck, and even a correct hit returns a feature the reader did not mean to choose. The answer is to change what is pickable, not to make the picking cleverer.

Grouping nearby points into a single symbol — a marker cluster, a hexagonal bin, a grid cell — gives the reader something large enough to hit and something meaningful to be told. A click on a group should not open a popup listing four thousand records; it should report the aggregate (how many, what the range is, what the dominant category is) and offer one action: zoom in until the group breaks apart. That single rule keeps interaction useful across five orders of magnitude of density, because the reader is always interacting with a symbol sized for a finger.

Two implementation details make the difference between a grouped map that feels solid and one that feels unpredictable. First, the group’s identity must be stable while the camera is still: if the grouping is recomputed on every frame, symbols shuffle under the cursor and a click lands on something that has moved. Recompute on moveend, not on move. Second, the aggregate shown in the popup must be computed from the same rows the group represents, not re-queried from the viewport — otherwise a reader who pans slightly sees the number change without the symbol changing, and stops trusting both.

At the other end of the scale, a layer with very few features benefits from the opposite treatment: enlarge the picking radius so that clicking near a small marker still selects it. Most renderers expose a tolerance parameter for exactly this; setting it to roughly half the symbol’s visual radius makes small points feel much less fiddly without introducing ambiguity, because there is nothing nearby to confuse them with.

Finally, remember that picking has a cost even when nothing is hit. On a dense layer the expensive case is the pointer moving across empty space, because the renderer still has to prove that nothing was hit. If profiling shows pointer moves dominating the frame budget, the fix is to reduce the number of interactive layers rather than to optimise the handler — a layer that no reader ever clicks should not be interactive at all.

Step 6 — Decide What Happens on an Empty Click

Clicking nothing is a real interaction and it deserves a designed answer. The three defensible behaviours are: close the open popup and clear the selection; close the popup but keep the selection; or do nothing at all. Which one is right depends on whether selection drives anything else in the interface.

When a selected feature also filters a chart or populates a side panel, clearing it on a stray click is hostile — the reader loses their place because they missed a five-pixel target. In that case keep the selection and provide an explicit clear control, visible whenever something is selected. When the selection only controls a popup, clearing on empty click is what readers expect, because the popup is the only thing that changed.

Whichever you choose, apply it consistently across every layer. A dashboard where clicking empty space clears the incidents selection but not the region selection feels broken even though each behaviour is individually reasonable, and readers will describe the whole map as unreliable rather than identifying the inconsistency.

The same reasoning applies to the Escape key. It should always close the topmost transient thing — first the popup, then a drawn selection, then a full-screen mode — and it should never navigate away. Wiring Escape to a single ordered dismissal stack takes a dozen lines and removes an entire category of “I cannot get out of this” reports.

What a popup may and may not contain

The allow-list is a security control and an editorial one at the same time. Deciding what appears — and in what order — is what keeps popups comparable between features and stops internal columns from being published by accident.

What belongs in a popup and what does not Identity, the value the map encodes, supporting context and a vintage belong in a popup. Join keys, geometry hashes, staging flags, internal user names and raw sentinel values do not — they are pipeline detail that an iterate-everything popup publishes by accident. An allow-list is an editorial decision as much as a safety one belongs in the popup the feature's name or identifier the value the map encodes, with units two or three supporting figures when the record was last updated enough to act on, little enough to read never appears join keys and surrogate ids geometry hashes and staging flags internal user names from updated_by raw sentinels such as −9999 all of which an iterate-everything popup publishes

Verification & Smoke-Test

  • Picking accuracy — click precisely on the boundary between two polygons and confirm the topmost layer wins deterministically, not randomly per frame.
  • Escaping — inject a feature whose name is <img src=x onerror=alert(1)> and confirm it renders as visible text.
  • Missing fields — remove an attribute from one feature and confirm the popup shows the empty marker rather than the word undefined.
  • Keyboard traversal — tab through the parallel feature list and confirm focus order matches reading order and that the map highlight follows focus.
  • Density — load the layer at full production density and confirm pointer moves stay under one frame with the browser’s performance profiler.

Troubleshooting

The tooltip flickers between two features

The hit test is running against overlapping layers and the topmost result alternates. Scope the query to a single layer, or sort the returned features by layer order and take the first deterministically.

Popups open behind the map controls

The popup container is being appended to the map pane rather than the control pane, so it inherits a lower stacking context. Append popups to the map’s overlay container and give them a stacking order above controls.

Clicking a marker on mobile also pans the map

Tap and drag are being distinguished by duration instead of movement. Track pointer displacement and treat anything under a few pixels as a tap regardless of how long it lasted.

Attributes are present in the source data but missing in popups

They were dropped during tiling. Vector tile generators retain only the attributes they are told to keep — see generating vector tiles from PostGIS with Tippecanoe — so the popup field list and the tiling configuration must be generated from the same object.

Hover highlight lags behind the pointer by a frame or two

The highlight is being applied by re-rendering the layer with a filter rather than by setting feature state. Filter changes rebuild the layer’s draw list; feature state changes only flip a flag the shader already reads. Use feature state for anything that changes as fast as a pointer moves.

The popup is rendered by the browser at interaction time, so it does not exist in a static capture. If the dashboard is printed or captured — see exporting Folium maps to static HTML with embedded assets — the same attributes need a non-interactive representation, usually a table beneath the map built from the same field allow-list.

A popup opens for the wrong feature after a data refresh

The popup was bound to a feature index rather than to a stable identifier, and the refresh reordered the rows. Bind interaction to the same identity field the pipeline uses for change detection, so a feature keeps its meaning across rebuilds even when its position in the file moves.

Gotchas & Edge Cases

  • A popup that contains a form or a control needs its own focus management: opening it should move focus into it, and closing it should return focus to whatever opened it.
  • Feature ids are required for hover state on vector tiles; without a stable id the renderer cannot express “this one is highlighted”.
  • A popup anchored to a feature’s centroid can point outside the feature for crescent-shaped or multipart geometry.
  • Opening a popup near the viewport edge should pan the map only if the popup would otherwise be clipped, and never during an active drag.
  • Popups containing links need rel="noopener" when they open new windows, and the link text must be escaped like any other value.
  • On a layer that updates on a schedule, an open popup can be showing values that no longer exist — re-render open popups after each data refresh, or close them.