Generating Sprite Sheets for MapLibre Styles

spreet --ratio 1 icons/ dist/sprite packs every SVG in a directory into one PNG and writes the JSON index that maps icon names to positions. Doing it well means two things beyond running the command: deriving the icon list from the style rather than from a folder, and producing a byte-stable atlas so an unchanged icon set does not republish on every build.

When to Use This

Whenever a style uses icon-image. That includes almost every basemap — POI markers, transport symbols, one-way arrows, shields — and any overlay that places symbols. The sprite is required even for a single icon; there is no way to reference an image from a style without one.

Specification Detail

Output Purpose Requested when
sprite.json Name → position, size and pixel ratio Always
sprite.png The 1× atlas Always
[email protected] Index for the 2× atlas Device pixel ratio above 1
[email protected] The 2× atlas Device pixel ratio above 1

The style names the base URL only:

json
{ "sprite": "https://tiles.example.com/v43/sprite" }

Each index entry describes one icon:

Key Meaning
x, y Top-left position in the atlas
width, height Size in atlas pixels
pixelRatio 1 or 2 — how many atlas pixels per CSS pixel
sdf true for a signed-distance-field icon that icon-color can tint
What one sprite index entry has to get rightA sprite index entry split into its position, dimensions, pixel ratio and SDF flag, with the consequence of each being wrong.INDEX ENTRYx, yatlas positionwrong here draws aneighbouring iconwidth, heightatlas pixelswrong here crops or padspixelRatio1 or 2wrong here halves ordoubles the drawn sizesdfbooleanfalse blocks icon-colortinting
The index and the PNG are a matched pair. A cached index against a newly packed atlas draws whichever icon happens to occupy those coordinates now.

Production Command

Derive the list from the style, generate both ratios, and verify:

bash
#!/usr/bin/env bash
set -euo pipefail
STYLE=style/base.json
SRC=icons
OUT=dist/v43

# 1. Which icons does the style actually name?
jq -r '
  [ .layers[] | (.layout // {})["icon-image"]
    | if type == "string" then .
      elif type == "array" then (.. | strings)
      else empty end ]
  | unique | .[]
' "$STYLE" | grep -v '^$' | sort -u > icons.manifest

# 2. Stage only those icons, so the atlas holds nothing unused
rm -rf build/icons && mkdir -p build/icons
while read -r NAME; do
  [ -f "$SRC/$NAME.svg" ] || { echo "missing source icon: $NAME"; exit 1; }
  cp "$SRC/$NAME.svg" "build/icons/$NAME.svg"
done < icons.manifest

# 3. Pack both pixel ratios
mkdir -p "$OUT"
spreet --unique --ratio 1 build/icons "$OUT/sprite"
spreet --unique --ratio 2 build/icons "$OUT/sprite@2x"

# 4. Every manifest icon must be in the index
comm -23 <(sort icons.manifest) <(jq -r 'keys[]' "$OUT/sprite.json" | sort) \
  | sed 's/^/not packed: /' | grep . && exit 1
echo "packed $(wc -l < icons.manifest) icons"

Step two is what keeps the atlas honest. Packing a whole design-system directory produces a sheet several times larger than needed, and every byte of it is fetched by every reader before an icon can be drawn.

From style to published atlasIcon names are extracted from the style, matching SVG sources are staged, both pixel ratios are packed, and the index is verified against the manifest before publishing.GENERATIONParse the styleevery icon-imageStage sourcesonly what is namedfails loudly on a missingSVGspreet x21x and 2xVerifymanifest vs index
The manifest is derived, never maintained. An icon removed from the style disappears from the next atlas automatically.
What packing the whole icon directory costs a readerCombined 1x and 2x atlas sizes for a manifest-scoped sheet of 40 icons, one of 180, and a whole design system of 600.ATLAS SIZEKB fetched before the first icon renders40 icons (manifest-scoped)152 KB180 icons436 KB600 icons (whole system)1.39 MB
Every byte here is on the path to the first rendered icon. A basemap typically uses a third of a design-system sheet.

Reproducibility

An atlas is packed by a bin-packing algorithm, and a different input order can produce a different layout with identical content. That matters because a reordered atlas is new bytes, which means a new content hash, which republishes an asset nothing about which has actually changed — and invalidates every reader’s cached copy.

Two measures make the output stable. Feed the packer a deterministic file order, which the while read loop above does by consuming a sorted manifest. And pin the packer version, since a bin-packing improvement between releases legitimately changes the layout.

With both in place, an unchanged icon set produces a byte-identical atlas, and the publish step can skip it entirely.

Interaction Effects

With SDF icons and theming. An SDF icon is a single-channel distance field that icon-color can tint at runtime, which lets one sheet serve light and dark themes. A full-colour icon cannot be tinted, so a themed map needs either SDF icons or a second sheet — and the second sheet is where theme drift creeps in.

With expressions. An icon-image built with concat or match cannot be enumerated statically, so the manifest is a lower bound. For styles that build icon names dynamically, add the possible values explicitly to the manifest rather than hoping the extraction found them.

With versioning. The index and the PNG must be deployed together and cached together. Publishing them under a version prefix with immutable headers is what guarantees a reader never mixes an old index with a new atlas.

Performance Impact

Icons packed 1× PNG 2× PNG Pack time
40 34 KB 118 KB 0.4 s
180 96 KB 340 KB 1.1 s
600 (whole design system) 290 KB 1.1 MB 3.6 s

The third row is the cost of packing a directory instead of a manifest: roughly a megabyte of atlas, fetched before any icon renders, of which a basemap typically uses a third.

Common Mistakes

Packing the source directory. Ships icons nobody references and inflates the fetch on the critical path.

Shipping only one pixel ratio. 1×-only is soft on most devices; 2×-only means no icons at all on a 1× display, since MapLibre requests the plain names first.

Serving the index and atlas with different cache lifetimes. A fresh index against a stale PNG draws the wrong icons, which is far more confusing than a missing one.

Assuming an unfound icon errors. MapLibre reserves the symbol’s space and draws nothing, so the layout looks right and the icons are simply absent.

FAQ

Can one style use several sprites?

MapLibre supports a sprite array with named sheets, referenced as sheet:icon. It is useful when one team owns the basemap icons and another owns an overlay’s, and it costs one extra fetch per sheet.

Do I need a 3× sprite?

Almost never. The visual difference above 2× is imperceptible for icons at typical map sizes, and it doubles the atlas again.

How do I add an icon without republishing everything?

You cannot — the atlas is one image and adding to it reflows the layout. That is precisely why the sheet is versioned and immutable rather than patched.

Does --unique change the index?

It deduplicates identical images, so two names can map to the same atlas position. The index still carries both names, so nothing in the style changes.