Part of the Popups, Tooltips & Feature Interaction guide.
Operative rule: highlight by setting feature state and reading it in a paint expression — never by changing a filter or re-adding the layer, because those rebuild the draw list on every pointer move.
Why Feature State Instead of a Filter
There are three ways to make one feature look different from its neighbours, and they differ by an order of magnitude in cost. Adding a second layer filtered to the hovered id means MapLibre re-evaluates a filter and rebuilds a draw list every time the pointer moves. Re-adding or re-styling the layer is worse. Feature state does neither: it writes a value into a per-feature slot the shader already reads, so the change costs one frame and no re-parse.
The requirement is a stable id per feature. Vector tiles frequently arrive without one, because the id is not part of the attribute payload unless something puts it there. promoteId solves this at the source level by nominating an existing property as the id, and it must be a value that is stable across rebuilds — the same durability requirement that applies to identifiers in a shared link.
Production-Ready Implementation
const SOURCE = "regions";
const LAYER = "regions-fill";
let hoveredId = null;
function clearHover(map) {
if (hoveredId === null) return;
map.setFeatureState({ source: SOURCE, sourceLayer: SOURCE, id: hoveredId },
{ hover: false });
hoveredId = null;
}
export function enableHoverHighlight(map) {
// The paint expression reads state; it is declared once and never touched again.
map.setPaintProperty(LAYER, "fill-opacity", [
"case",
["boolean", ["feature-state", "hover"], false], 0.95,
0.7,
]);
map.setPaintProperty(LAYER, "fill-outline-color", [
"case",
["boolean", ["feature-state", "hover"], false], "#0C6E94",
"#FFFFFF",
]);
map.on("mousemove", LAYER, (event) => {
const feature = event.features && event.features[0];
if (!feature || feature.id === undefined) return; // no id → no state
if (hoveredId === feature.id) return; // same feature, nothing to do
clearHover(map);
hoveredId = feature.id;
map.setFeatureState({ source: SOURCE, sourceLayer: SOURCE, id: hoveredId },
{ hover: true });
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", LAYER, () => {
clearHover(map);
map.getCanvas().style.cursor = "";
});
// Style changes wipe feature state, so re-arm after any restyle.
map.on("styledata", () => { hoveredId = null; });
}
The hoveredId === feature.id early return is what keeps this cheap: without it the code writes state on every single pointer move even when nothing changed. The styledata handler exists because a style swap discards feature state along with everything else the style owned — the same teardown behaviour that catches people out when switching basemaps at runtime.
Where Feature Ids Come From
Composing Highlight with Data-Driven Colour
Highlighting rarely stands alone: the layer is usually already coloured by a data-driven expression. The two must compose rather than compete, and the way to do that is to make the highlight a case whose default branch is the existing ramp expression.
One further habit is worth adopting: wrap every setFeatureState call in a small helper that closes over the source and source-layer ids. It removes the most common silent failure — a call missing sourceLayer on a vector source — and it gives you one place to add logging when a highlight mysteriously does not appear.
Verification Steps
- Log
feature.idon the firstmousemove; if it isundefined, fix the id before anything else. - Move the pointer quickly across many features and confirm only one is highlighted at a time.
- Move the pointer off the map entirely and confirm the highlight clears.
- Swap the basemap style and confirm hovering still works afterwards — that is what the
styledatahandler is for. - Profile a pointer sweep across the densest area and confirm frame time stays under budget.
Common Errors & Fixes
Two features stay highlighted at once
clearHover is not being called before setting the new id, or the early return is comparing the wrong values. Track exactly one id in a single variable and clear it before every set.
The highlight flickers along polygon boundaries
The pointer is alternating between two overlapping layers. Bind the handler to one layer id rather than to the map, so only that layer’s features are considered.
Hover works but the cursor never changes
getCanvas().style.cursor is being set on the wrong element — the map container rather than the canvas — or is being reset by a later handler. Set it in both the enter and leave paths of the same handler.
Feature state resets whenever new tiles load
That is expected: state lives per rendered feature and a tile reload clears it for features in that tile. Re-apply the state for the currently hovered id inside a sourcedata handler if the layer streams tiles while the reader hovers.
Extending Feature State Beyond Hover
Hover is the obvious use, but feature state is a general per-feature flag store, and the same mechanism carries several other interactions at the same cost. A selected flag drives a persistent highlight that survives the pointer leaving. A filtered flag drives dimming rather than hiding, which keeps excluded features visible as context instead of removing them from the map entirely. A stale flag can mark features whose data is older than a threshold, so a partially refreshed layer is visually honest.
Because each flag is read by an expression, they compose without interfering. A case expression can check selected first, then hover, then fall through to the data-driven ramp, and the result is a layer where selection outranks hover and both outrank the base symbology. Adding a fourth state later is one more branch, not a rewrite.
Two operational cautions apply as the number of flagged features grows. Feature state is stored per rendered feature, so setting it on thousands of features at once — for example to express a filter result — costs a pass over that set and is better expressed as a filter or a data-driven expression instead. And state is scoped to a source and source layer, so a dashboard with several sources needs the source id threaded through every call; a helper that closes over the source and layer ids is worth writing on the first day rather than the tenth.
The last property worth knowing is that feature state is not serialisable. It does not survive a reload, and it is not part of any shared link. Anything a reader would expect to persist — the selection in particular — has to live in the application’s own state and be re-applied after load, which is exactly the arrangement described in the dashboard state guide.
Gotchas & Edge Cases
- Feature state is scoped to a source and a source layer; omitting
sourceLayeron a vector source silently does nothing, with no warning in the console. promoteIdmust name a property that exists in the tiles, not one that exists in the source data — an attribute dropped during tiling produces features with undefined ids.- Numeric ids and string ids are different keys. A pipeline that emits integers in one layer and strings in another will appear to work until the two are used together.
- Setting state on a feature that is not currently rendered is a no-op, not an error; re-apply state for the active selection when new tiles arrive.
- Multipart geometries share a single id, so highlighting one part highlights all of them — usually desirable, occasionally surprising for archipelagos and enclaves.
- Feature state does not survive
setStyle, a source reload or a page refresh, which is why anything a reader expects to persist belongs in application state instead.
Related
- Popups, Tooltips & Feature Interaction — the parent guide covering affordances and hit testing
- Data-Driven Styling with MapLibre Expressions from Python — the ramp this highlight composes with
- Binding Popups to GeoJSON Features in Folium Safely — the equivalent interaction in a baked pipeline
- Switching Between OpenStreetMap and Mapbox Basemaps at Runtime — why a style swap wipes feature state