Sprites and icon images for MapLibre GL JS — choosing a `Marker`, a sprite, or `addImage`; the `sprite` base URL and its `{id, url}` array form for several sheets; self-hosting and building sprites; runtime images, SVGs included, with `addImage`; finding a missing icon with the console warning and `hasImage`; and route shields that render as bare numbers. Use when deciding how to put icons on a map, when a symbol layer's icons never appear, when adding your own icons to a style whose sprite you do not control, or when shields are missing their badge.
Install
npx skillscat add maplibre/maplibre-agent-skills/maplibre-sprites-icons Install via the SkillsCat registry.
MapLibre Sprites and Icons
An icon on a MapLibre GL JS map is either part of the map, drawn by a symbol layer from a sprite (a PNG atlas plus a JSON index served from the style's sprite URL) or from an image registered at runtime with addImage, or on top of the map as a Marker, outside the style. For icon color, halos, and figure-ground against imagery, see maplibre-cartography; for text and glyph setup, see maplibre-fonts-glyphs.
When to Use This Skill
- Choosing between a
Marker, a sprite, andaddImagefor icons on a map - A symbol layer's icons do not render
- Adding your own icon sheet to a style whose
spriteyou do not control - Setting up, self-hosting, or building a
spritefrom SVGs (forglyphs, see maplibre-fonts-glyphs) - Registering icons, including SVGs, for a layer your app adds at runtime
- Route shields render as bare numbers or missing badges
- Writing tooling that reads or generates symbol layers and has to recognize a shield
Choosing a Marker, a sprite, or addImage
Two questions, in order; the sections below follow them.
- Is the icon part of the map, or on top of it? Part of the map means a symbol layer's
icon-image: placed together with the labels, hidden when it collides, and drawn in layer order, for a point dataset of any size. A small number of annotations on top of the map, that people drag or that need their own HTML, are `Marker`s. - For a symbol layer, where does the image come from?
- The style. POIs, town spots, shields, pattern textures, and any other icon or texture layered into the style, where it takes part in the visual hierarchy and collisions: a sprite. Your own set on a basemap whose sprite you do not control is a second sheet, still a sprite.
- Your app, at runtime. Icons for a layer the app adds, such as a GeoJSON source, that no sheet carries: `addImage`. Use it deliberately. Each image is its own request and
addImagecall, where a sprite arrives as one PNG and one JSON, and its icons collide with the style's labels and with each other like sprite icons.
Sprites
The style's sprite value is a base URL with no file extension; MapLibre appends .json, .png, and @2x variants itself.[1] A symbol layer names a sprite image in icon-image (other layers in their *-pattern property), and the name must exactly match an ID in the sprite JSON index or the icon is not drawn.
{
"sprite": "https://example.com/sprites/poi",
"layers": [
{
"id": "cafes",
"type": "symbol",
"source": "places",
"source-layer": "poi",
"layout": { "icon-image": "cafe" }
}
]
}https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite works for testing; do not use it in production.
Multiple sprite sheets in one style
sprite is not limited to a single string. Since MapLibre GL JS 3.0 (#1805) it also accepts an array of {id, url} objects,[1] so a style can load its own icons alongside a basemap provider's sheet. Before 3.0 a style could name only one sprite, so adding your own icons meant merging them into the provider's sheet; with the array form, each sheet stays where it is hosted.
{
"sprite": [
{ "id": "default", "url": "https://tiles.openfreemap.org/sprites/ofm_f384/ofm" },
{ "id": "my-icons", "url": "https://example.com/sprites/poi" }
]
}Rules that follow from that form:
- Reference images by
id:image-name—"icon-image": "my-icons:cafe". An unprefixed"cafe"resolves against thedefaultsheet only. - The
iddefaultis the one exception: its images take no prefix, which is what keeps an existing style'sicon-imagevalues working when you convert its stringspriteto the array form. Give the basemap's sheetid: "default"and the shield and POI layers already in the style keep resolving unchanged. - All ids and all URLs must be unique. Duplicates are a validation error (
all the sprites' ids must be unique, but <id> is duplicated), not a last-one-wins merge. - Each URL is still extension-less — MapLibre appends
.json,.png, and the@2xvariants per entry, exactly as for the string form. - From code,
map.addSprite('my-icons', 'https://example.com/sprites/poi')adds a sheet once the style has loaded; a stringspritebecomes the array'sdefaultentry.[3] - ❌
{ "id": "my-icons", "url": "https://example.com/sprites/poi.json" }→ requestspoi.json.json - ❌ Typing
spriteasstringin your own tooling: an array value stringifies to[object Object], and the loader rejects it withInvalid sprite URL "[object Object]", must be absolute. Handle both shapes wherever you read a style'ssprite.
Self-hosted sprites
To avoid third-party dependencies, copy an existing sprite directory (PNG + JSON, plus any @2x files) from a style or tileset provider and host it under your own domain, pointing the style's sprite property at its base URL. Always check the provider's license before republishing and add attribution if required.
Host sprite assets on a static host you control (same origin as the style). Do not point production styles at raw.githubusercontent.com. Raw is for serving repository blobs, not production assets: anonymous requests are aggressively rate-limited so real users see intermittent HTTP 429s [5], caching is fixed at five minutes with no control, there is no SLA, and private-repo URLs return 404 to everyone but authenticated collaborators (it works for you while logged in, then fails for every other user) [6].
Building a sprite from SVGs
Generate sprite assets from a directory of SVGs with tools such as spritezero, spreet, or Martin.
Useful icon sources include Maki and Temaki. These are common source repositories for map-style SVG icons, but check each repository's license before republishing derived sprite assets.
Runtime images with addImage
For a layer your app adds at runtime, register each image under an ID once the style has loaded, then name that ID in the layer's icon-image. Check the ID with hasImage() first: addImage on an ID the style already has fires an error (An image named "<id>" already exists.) instead of replacing it.[3] [8]
map.on('load', async () => {
// PNG, WebP, or JPEG: loadImage() resolves to a response whose data is the image
const { data } = await map.loadImage('https://example.com/icons/cafe.png');
if (!map.hasImage('cafe')) map.addImage('cafe', data);
// SVG: an Image element, rasterized at its width and height
const park = new Image(24, 24);
park.crossOrigin = 'anonymous'; // or addImage throws on a cross-origin SVG
await new Promise((resolve, reject) => {
park.onload = resolve;
park.onerror = reject;
park.src = 'https://example.com/icons/park.svg';
});
if (!map.hasImage('park')) map.addImage('park', park);
map.addSource('venues', { type: 'geojson', data: 'https://example.com/venues.geojson' });
map.addLayer({
id: 'venues',
type: 'symbol',
source: 'venues',
layout: { 'icon-image': ['get', 'category'] } // 'cafe' or 'park'
});
});- ❌
map.loadImage(url, callback). GL JS 4.0.0 removed the callback form,[4] so the callback never runs;awaitthe promise.[3] - ❌ An SVG through
map.loadImage(), which takes PNG, WebP, or JPEG.[3] To load SVGs on demand, GL JS 6'ssetMissingStyleImageResolvertakes the sameImageroute, as in MapLibre's Display a remote SVG symbol example. - ❌
sdf: trueon an ordinary SVG. An image markedsdfhas its alpha read as a distance field and every opaque pixel paintedicon-color(default black), so a multicolor icon turns into a one-color silhouette. Withoutsdf,icon-colorandicon-halo-*do nothing: they only work on SDF icons.[2] - A symbol layer hides icons that collide by default (
icon-allow-overlapisfalse, andicon-overlapoverrides it when set);symbol-sort-keydecides which survive. Turning overlap on (icon-allow-overlap: true, oricon-overlap: "always") draws every icon without checking collisions, which the GL JS large-data guide suggests at high feature counts; the icons then stack. - ❌
"icon-optional": trueto make colliding icons disappear. It applies only to a symbol with both an icon and text, letting the text show without its icon when the icon collides and the text does not; on a layer with notext-fieldit changes nothing.
addImage's options take the same per-image metadata as the sprite index: pixelRatio, sdf, stretchX, stretchY, content, textFitWidth, and textFitHeight.[3]
Markers
A Marker[7] is an HTML element over the map canvas, outside the style: drawn above every layer, labels included, and never part of collision detection.
new maplibregl.Marker({ draggable: true })
.setLngLat([-122.42, 37.77])
.setPopup(new maplibregl.Popup().setText('Pickup point'))
.addTo(map);- A
Markeris one DOM element, repositioned on every camera move. Fine for a small number of annotations on top of the map; for a point dataset, put the points in a GeoJSON source and draw them as a symbol layer.
When an icon does not render
GL JS reports every icon it cannot find: the map fires styleimagemissing, and the console warns once per image ID, Image "<id>" could not be loaded.[8] A sprite URL that fails to load also fires the map's error event, which goes to console.error when nothing listens for it.[8]
After the load event, map.hasImage(id) tells the two causes apart:[3]
- The sheet never loaded when
hasImage()isfalsefor IDs the sprite JSON does contain. Check that the style setssprite, that its URL has no extension, that its.jsonand.pngreturn 200, and CORS. - The sheet loaded without that ID when other sprite IDs return
true. Compare theicon-imagevalue with the sprite JSON's keys, including theid:prefix of a non-default sheet;map.listImages()returns every ID the map has.
Broken route shields
Broken-looking route shields (bare floating numbers, missing badges) are almost always a missing sprite image. The shield number is text (font) and usually renders fine; the badge behind it is an icon-image from the sprite. Diagnose in this order:
- Confirm glyphs load. Probe the
glyphsserver for the exacttext-fontnames and expect HTTP 200. If they 200, you can deduce the culprit is a missing sprite image or broken reference. - Confirm the sprite carries the shield images. OSM Bright's shield layers use
icon-image: "{network}_{ref_length}"for US networks (e.g.us-interstate_2,us-highway_3,us-state_2) androad_{ref_length}for other refs; OSM Liberty's usedefault_{ref_length}. A missing icon is omitted, so grep the sprite JSON for those keys.
The https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite and https://openmaptiles.github.io/osm-bright-gl-style/sprite sheets currently carry road_1–_6, us-state_1–_6, us-highway_1–_3, and us-interstate_1–_3 (_1–_5 on openmaptiles.github.io), but a minimal or custom sprite may ship only the generic road_*. If yours lacks the shield images and your tiles populate network, ref, and ref_length (the OSM US OpenMapTiles tiles do), point sprite at one that has them — the {network}_{ref_length} style layers then resolve with no layer edits.
A shield is a badge image behind the route ref, sized one of two ways. OSM Bright and OSM Liberty pick a pre-drawn badge per ref length, using the OpenMapTiles ref_length attribute to select the right one.[9] OSM Bright's interstate shield layer, abridged:
{
"id": "highway-shield-us-interstate",
"layout": {
"icon-image": "{network}_{ref_length}",
"symbol-placement": {
"base": 1,
"stops": [
[7, "point"],
[7, "line"],
[8, "line"]
]
},
"text-field": "{ref}"
}
}To use one badge image for every ref length, make it stretchable. In the badge's sprite JSON entry, stretchX and stretchY list the pixel ranges that may stretch, and content is the box the text must fit inside. On the shield layer, set icon-text-fit to "both" or "width", plus icon-text-fit-padding for a margin around the text.[1] [2] I-5 and I-405 then share one image at two widths. OSM Bright and OSM Liberty use no stretchable badges, so a tool that recognizes shield layers must accept both kinds. Only a stretchable badge sets icon-text-fit (default none), and text-anchor defaults to center, so leaving it unset says nothing about a layer.
Code that reads layout values out of a style must not assume they are strings. OSM Bright's three shield layers and OSM Liberty's one set symbol-placement with a legacy zoom function, an object with stops that switches from point to line. Any symbol layout value can also be an expression, which is an array. A check like layer.layout['symbol-placement'] === 'point' is false for those layers, so the code skips them. Check whether the value is a string, a zoom function, or an expression before comparing it.
Related Skills
- **maplibre-cartography** — Icon color,
icon-halo-color/icon-halo-width, and figure-ground for point symbols on aerial imagery; style layer ordering. - **maplibre-fonts-glyphs** — The
glyphsURL, font PBFs, and the GL JS local-font fallback; the text half of a shield. - **maplibre-source-wiring** — Where
spritesits among a style's other root properties, and CORS for self-hosted assets. - **maplibre-v6-migration** —
styleimagemissingis notify-only in v6;setMissingStyleImageResolveris how a v6 map supplies anicon-imagethat is not in the sheet.
References
- **Style Spec: `sprite`** — the string and
{id, url}array forms, image-name prefixing, thedefaultid, and the sprite index file'scontent,stretchX/stretchY, andtextFitWidth/textFitHeightfields - **Style Spec: symbol layer properties** —
icon-text-fitvalues (nonedefault,width,height,both) andicon-text-fit-padding;icon-colorandicon-halo-*are SDF-only - **`Map.addImage()` (MapLibre GL JS API)** — with
hasImage(),listImages(),addSprite(), andloadImage()on the same page:loadImagetakes PNG, WebP, or JPEG and resolves to a response whosedatais the image - **MapLibre GL JS CHANGELOG** — "Add support for multiple
spritedeclarations in one style file" ships in 3.0.0;map.loadImagereturns aPromiseand drops its callback in 4.0.0 (#3233, #3422) - **Unauthenticated rate limits on `raw.githubusercontent.com` (GitHub Community Discussion)** — anonymous requests are rate-limited; production traffic sees intermittent HTTP 429
- **`raw.githubusercontent.com` and private repositories (GitHub Community Discussion)** — private-repo raw URLs return 404/403 to anonymous requests
- **`Marker` (MapLibre GL JS API)** — the
draggableoption,setLngLat, andsetPopup - **GL JS 6.11.2 `image_manager.ts`** —
styleimagemissingand the missing-image warning; the duplicate-ID error is in `style.ts`, and anerrorevent with no listener goes toconsole.errorin `evented.ts` - **OpenMapTiles `transportation_name` layer** —
ref_length: "Useful for having a shield icon as background for labeling motorways."
This skill is a snapshot. Where a primary source contradicts it — the References above, MapLibre's current documentation, or what MapLibre does when you run it — that source wins. Follow it, then report the disagreement, citing the source and your MapLibre version: editing your installed copy helps no one else and is overwritten on the next update.