maplibre

maplibre-sprites-icons

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.

maplibre 155 10 Updated 1w ago
GitHub

Install

npx skillscat add maplibre/maplibre-agent-skills/maplibre-sprites-icons

Install via the SkillsCat registry.

SKILL.md

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, and addImage for icons on a map
  • A symbol layer's icons do not render
  • Adding your own icon sheet to a style whose sprite you do not control
  • Setting up, self-hosting, or building a sprite from SVGs (for glyphs, 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.

  1. 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.
  2. 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 addImage call, 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 the default sheet only.
  • The id default is the one exception: its images take no prefix, which is what keeps an existing style's icon-image values working when you convert its string sprite to the array form. Give the basemap's sheet id: "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 @2x variants 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 string sprite becomes the array's default entry.[3]
  • ❌ { "id": "my-icons", "url": "https://example.com/sprites/poi.json" } → requests poi.json.json
  • ❌ Typing sprite as string in your own tooling: an array value stringifies to [object Object], and the loader rejects it with Invalid sprite URL "[object Object]", must be absolute. Handle both shapes wherever you read a style's sprite.

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; await the promise.[3]
  • ❌ An SVG through map.loadImage(), which takes PNG, WebP, or JPEG.[3] To load SVGs on demand, GL JS 6's setMissingStyleImageResolver takes the same Image route, as in MapLibre's Display a remote SVG symbol example.
  • ❌ sdf: true on an ordinary SVG. An image marked sdf has its alpha read as a distance field and every opaque pixel painted icon-color (default black), so a multicolor icon turns into a one-color silhouette. Without sdf, icon-color and icon-halo-* do nothing: they only work on SDF icons.[2]
  • A symbol layer hides icons that collide by default (icon-allow-overlap is false, and icon-overlap overrides it when set); symbol-sort-key decides which survive. Turning overlap on (icon-allow-overlap: true, or icon-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": true to 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 no text-field it 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 Marker is 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() is false for IDs the sprite JSON does contain. Check that the style sets sprite, that its URL has no extension, that its .json and .png return 200, and CORS.
  • The sheet loaded without that ID when other sprite IDs return true. Compare the icon-image value with the sprite JSON's keys, including the id: 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:

  1. Confirm glyphs load. Probe the glyphs server for the exact text-font names and expect HTTP 200. If they 200, you can deduce the culprit is a missing sprite image or broken reference.
  2. 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) and road_{ref_length} for other refs; OSM Liberty's use default_{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 glyphs URL, font PBFs, and the GL JS local-font fallback; the text half of a shield.
  • **maplibre-source-wiring** — Where sprite sits among a style's other root properties, and CORS for self-hosted assets.
  • **maplibre-v6-migration** — styleimagemissing is notify-only in v6; setMissingStyleImageResolver is how a v6 map supplies an icon-image that is not in the sheet.

References

  1. **Style Spec: `sprite`** — the string and {id, url} array forms, image-name prefixing, the default id, and the sprite index file's content, stretchX/stretchY, and textFitWidth/textFitHeight fields
  2. **Style Spec: symbol layer properties** — icon-text-fit values (none default, width, height, both) and icon-text-fit-padding; icon-color and icon-halo-* are SDF-only
  3. **`Map.addImage()` (MapLibre GL JS API)** — with hasImage(), listImages(), addSprite(), and loadImage() on the same page: loadImage takes PNG, WebP, or JPEG and resolves to a response whose data is the image
  4. **MapLibre GL JS CHANGELOG** — "Add support for multiple sprite declarations in one style file" ships in 3.0.0; map.loadImage returns a Promise and drops its callback in 4.0.0 (#3233, #3422)
  5. **Unauthenticated rate limits on `raw.githubusercontent.com` (GitHub Community Discussion)** — anonymous requests are rate-limited; production traffic sees intermittent HTTP 429
  6. **`raw.githubusercontent.com` and private repositories (GitHub Community Discussion)** — private-repo raw URLs return 404/403 to anonymous requests
  7. **`Marker` (MapLibre GL JS API)** — the draggable option, setLngLat, and setPopup
  8. **GL JS 6.11.2 `image_manager.ts`** — styleimagemissing and the missing-image warning; the duplicate-ID error is in `style.ts`, and an error event with no listener goes to console.error in `evented.ts`
  9. **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.