The problem
MapLibre places labels next to their features. When features are close together, labels overlap and get hidden. The built in collision detection stops overlap but does not move labels elsewhere or connect them back to their feature.
Callouts solve this. The label sits in open space where text is readable. A connector line ties the label to its feature. Multiple callouts stack side by side without overlapping.
The idea came from ESRI Callout Line 3D, a feature in the ArcGIS API for JavaScript. I wanted the same thing for MapLibre.
How it works
The plugin only looks at features that are currently visible on screen. When you pan or zoom, it asks MapLibre “what features can the user see right now?” and ignores everything else. This is viewport culling.
While you drag, rotate, or tilt the map, the plugin does almost nothing. It just moves the existing labels to their new screen positions with map.project(). No new queries, no collision checks, no DOM creation. That heavy work only happens when you stop moving the map. And even then it runs at most once per frame, scheduled with requestAnimationFrame.
The collision system is simple greedy. Features are sorted by priority (for example, number of ships in a port). The highest priority labels are placed first. Once a spot is taken, the next label tries the next best position. If there is no room, the label is skipped and the feature stays as a plain dot on the map.
DOM elements (the label divs) are created once and reused. When a label is no longer needed, its div is hidden instead of removed. This keeps the DOM stable.
There are also zoom and pitch limits. If the map is too far zoomed out or too tilted, labels are hidden and nothing is processed.
Per feature styling
Each callout can be styled individually through callback functions. Every feature gets its own colors for background, text, border, connector line, and marker dot. A hash function maps each feature ID to a pastel color scheme, so the same feature always gets the same colors.
lineColor: (p) => p.c_line,
labelBackground: (p) => p.c_bg,
labelColor: (p) => p.c_text,
labelBorder: (p) => "1px solid " + p.c_border,
How to use
Install with npm:
npm install @leoneljdias/maplibre-label-callout
Or load from a script tag:
<script src="https://unpkg.com/@leoneljdias/maplibre-label-callout"></script>
Basic usage:
import LineCallout3D from "@leoneljdias/maplibre-label-callout";
const callouts = new LineCallout3D(map, {
data: myGeoJSON,
idProperty: "id",
template: (p) => `<div>${p.name}</div>`,
maxLabels: 25,
});
Bundle size
The plugin is about 3KB minified. No dependencies beyond MapLibre GL JS. Connector lines and markers use MapLibre’s built in layer types (line, circle), so there are no custom shaders or extra rendering overhead.