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.
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.
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.
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.
Popup content differs between the map and an export or print
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.
Related
- Core Mapping Architecture & Rendering — the section this interaction layer belongs to
- Symbology & Data-Driven Styling — the visual encoding readers are interrogating
- Choosing a Renderer: Folium vs MapLibre GL vs PyDeck — how picking differs between the three
- Layer Management & Toggling — where selection state belongs
- Making Python-Generated Maps Responsive on Mobile — the touch parity work in detail