MapLibre Cartography
MapLibre renders exactly what you describe in your style JSON. This skill covers how to describe it well: choosing label colors for readability on any basemap, building a coherent visual hierarchy, sourcing and self-hosting fonts and icons, and ordering layers correctly.
When to Use This Skill
- Choosing label and for a new or migrated style
- Map labels are hard to read against a background (imagery, dark basemap, complex vector)
- Setting up and for a custom or self-hosted style
- Injecting your own data layers into an existing basemap without covering labels
- Making point symbols, markers, or custom icons readable on satellite/aerial imagery
- Restyling roads from a light-basemap vector palette so they sit in (not on top of) imagery
- Route shields render as bare numbers or missing badges
- Auditing a style for contrast accessibility
Basemap Type Determines Label Colors
MapLibre places labels dynamically, so you cannot mask the background behind each label as you would on a static map. Instead, choose a
that separates the label from every background it might land on, and a
that reads against the halo:
| Basemap type | Background | Recommended text color | Recommended halo |
|---|
| Light vector (streets, OpenFreeMap positron) | Pale/white | Dark ( or similar) | Light semi-transparent () |
| Dark vector (dark-matter, navigation night) | Dark | White or near-white () | Dark semi-transparent () |
| Satellite or aerial imagery (NAIP, Sentinel-2) | Unpredictable — bright crops, dark forests, urban rooftops | White () | Dark semi-transparent () |
The most common mistake is a white halo with no transparency: unless the background is pure white, it disrupts the spatial connection between the label and the feature it labels — add transparency. The second is reusing a light-vector palette over imagery, where it fails on dark terrain, forests, and water.
On imagery, always use white text and a dark semi-transparent halo (
,
"text-halo-color": "rgba(0,0,0,0.75)"
,
).
For tinted labels (parks, water, POIs), use a light tint of the semantic color (
parks,
water) rather than the dark saturated version: tints read against dark halos while keeping semantic meaning, where full-saturation colors contrast poorly at small sizes.
Halo width
Wider halos increase legibility but add visual weight. Typical values:
| Context | |
|---|
| Body labels (city, town, village) | 0.8–1.5 |
| Country / continent (large text) | 1.5–2.0 |
| Small POI or peak labels | 0.8–1.2 |
| Water / park labels with colored text | 1.0–1.5 |
is in pixels relative to the text. The halo must not bleed into adjacent labels: keep it tight at small text sizes and add transparency.
Point Symbols and Icons on Imagery
Markers face the same figure-ground problem as labels, but with different tools. A colored icon on aerial imagery competes with an unpredictable, busy, desaturated photographic background.
- You cannot separate a symbol from a background that owns its hue. A green icon over green parkland, a brown icon over bare soil: both camouflage. Most aerial imagery is low-saturation, so the axis the background is weakest on is chroma. A saturated fill (amber, terracotta) separates while still reading as a natural, earthy color. Shifting hue alone, toward a different earth tone, does not help if that hue is also in the scene.
- Carve the symbol out with a casing, exactly as you would halo a label. A thin light casing reads against dark canopy and water; a darker edge holds against bright soil and rooftops. Keep it thin: a fat ring reads as a sticker. Terminology: a halo contrasts the background to lift the symbol off it; a knockout matches the background to mask busy texture immediately around the symbol. Both buy separation.
- Flat fills read as stickers on a photo. Give landform or 3D symbols dimensional cues. A gradient (lighter on the lit slope, darker on the shaded slope) models form. A contact shadow, a blurred flattened ellipse pooled under the base, anchors the symbol to the ground far better than an offset drop-shadow, which makes it look like it floats. Match the symbol's lighting and shadow direction to the basemap's
hillshade-illumination-direction
(commonly NW, 315°) so the symbol sits in the same light as the terrain.
SVG icons via : when loading an SVG into a sprite image at runtime (fetch the SVG, decode it as an
, then
), the SVG rasterizes at decode time, so
and
filters bake in correctly.[1] Two gotchas: pad the
so halos and shadows are not clipped at the icon edge, and keep
/
proportional to the
or the glyph distorts. Use
"icon-allow-overlap": true
for dense point data.
Visual Hierarchy
A well-ordered label hierarchy means the most important features dominate at the appropriate zoom level. MapLibre controls hierarchy through text size, font weight, letter spacing, and zoom-range visibility.
Text size by feature class
Text size should decrease as feature importance decreases. These stops are a starting point; adjust for your tile schema and zoom range:
| Label type | Base zoom | Max zoom | Size range (px) |
|---|
| Continent | 1 | 4 | 14–20 |
| Country | 2 | 7 | 11–17 |
| City | 7 | 11 | 14–24 |
| Town | 10 | 14 | 11–16 |
| Village / hamlet | 11 | 16 | 10–14 |
| Airport / POI | 10 | 16 | 12–14 |
| Peak / summit | 8 | 13 | 10–11 |
Points of interest (POI) labels should be visually lighter (smaller, thinner weight) than settlement labels at the same zoom. On an imagery map showing gentle terrain like rolling hills, keep peak labels smaller than airport labels — these are elevation markers, not dominant landmarks.
Font weight
Use font weight to reinforce hierarchy via
(e.g.
):
Bold for countries and capital cities,
Regular for towns, cities, and most labels,
Italic for water bodies, parks, and regions (a cartographic convention no longer always observed).
Multi-line labels
For compact two-line labels (e.g. a symbol character above a name), reduce
below 1.0 to avoid excessive spacing:
json
{
"text-field": "△\n{name:latin}",
"text-line-height": 0.9,
"text-max-width": 8
}
Values around 0.9 produce tight, readable two-line labels at small sizes. Do not go below ~0.8 or lines will overlap at standard font sizes.
Text transform and spacing
- Use
"text-transform": "uppercase"
for country and continent labels — a conventional cartographic practice
- Use
"text-letter-spacing": 0.05–0.1
for region labels to spread them across a territory
Styling Vector Roads Over Imagery
Vector road palettes from light-basemap styles (OSM Bright, OSM Liberty) are tuned to pop on pale paper. Dropped on imagery they dominate: high saturation against a desaturated photo, warm hues advance toward the eye, full opacity. Invert the priority. The imagery is the subject; roads are a reference overlay.
- Desaturate hard. Move fills and casings toward neutral greys or muted tones. The bright orange/yellow road hierarchy (, , ) is the most common offender; replace fills with light greys and casings with a darker grey or a deep same-hue color.
- Keep hierarchy in width and value, not hue. The width ramps already encode motorway > residential; you do not need loud color to say it.
- Opaque, not transparent. Semi-transparent roads let imagery texture bleed through and flatten the whole map. Prefer opaque fills with a value-contained casing for crisp, layered roads.
- The casing contains the road. A casing darker than the fill draws the median line that keeps dual carriageways from merging into one blob. A knockout casing, a deeper shade of the fill's own hue rather than a foreign black, defines the edge without a harsh cartoon outline.
- Control brightness by zoom. Roads tuned at high zoom often read too heavy at the opening (low) zoom, where only thin major roads show and the casing dominates. Interpolate color by zoom: casing dark at low zoom lightening as you zoom in, fills the lightest element brightening as the network fills in.
json
{
"line-color": ["interpolate", ["linear"], ["zoom"], 10, "#454545", 12, "#5a5a5a", 14, "#6e6e6e"]
}
Typography: Glyphs and Font Stacks
MapLibre renders text using
SDF (signed-distance field) glyphs — precomputed font files that scale cleanly at any zoom or screen density. Glyphs are served from a URL matching the pattern in the style's
field.
In MapLibre GL JS ≥ 5.11.0 (
PR #4564), a missing glyph PBF is no longer fatal — MapLibre renders the glyph locally via TinySDF instead, treating
as a cascading list of local/web font names (
example). That fallback is environment-dependent (it needs a matching font available to the browser or OS) and
GL JS only —
MapLibre Native still requires glyphs served from a URL matching this field. Production styles should still serve glyphs explicitly rather than relying on the fallback.
Setting the glyphs URL
The style's
field is a URL template ending in
(e.g.
https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf
), where
is the comma-joined
list and
a Unicode range — full mechanics:
style spec — glyphs.
is itself a fallback list — see
Noto for global maps below.
Font options
| Source | Fonts available | Notes |
|---|
demotiles.maplibre.org/font
| Noto Sans (Latin, Arabic, CJK, etc.), Noto Sans Bold, Italic | Free, publicly hosted; good for prototyping |
| OpenMapTiles | Klokantech Noto Sans family | Matched to OMT schema styles |
| Self-hosted, existing font | Reuse prebuilt PBFs (openmaptiles/fonts, UNDP-Data/fonts, or your current server's tree) | Full control; no generation needed for standard fonts |
| Self-hosted, custom font | Generate PBFs from your own TTF/OTF | Only needed when no prebuilt PBF set exists for the font |
For standard fonts (Noto Sans, Open Sans, Roboto, and similar), you do not need to generate anything. The simplest no-generation path is to copy the
tree a glyph server already serves (e.g. the one your style currently points at) onto your own origin. Projects such as
openmaptiles/fonts and
UNDP-Data/fonts package the common standard fonts as glyph PBFs you can build or pull — note both also run hosted endpoints, which are themselves third-party servers to avoid if self-hosting is the point. Point the style's
field at your own URL template; the font names in your
arrays must exactly match the served font-stack folder names.
Generating glyphs from a TTF/OTF is a separate, heavier task — only needed for a custom or brand font with no existing PBF set. Use
Font Maker or
fontnik to produce the
files, then serve and reference them the same way as above.
Noto for global maps
Noto ("no tofu") is Google's open-source family built for near-universal Unicode coverage: Noto Sans covers Latin/Greek/Cyrillic, and script-specific fonts (Noto Sans Arabic, Noto Sans Devanagari, Noto Sans Thai, the region-specific Noto Sans CJK SC/TC/JP/KR) extend it. How you handle non-Latin text depends on the script, and CJK is the case people most often get wrong.
CJK (Chinese, Japanese, Korean) — rendered locally by default; do not serve CJK glyph PBFs. MapLibre GL JS's
map option defaults to
, so CJK characters are generated on-device (TinySDF) and the style's
is
ignored for them (except the weight keyword). This exists because CJK text has poor locality across Unicode ranges — a single tile can otherwise trigger dozens of large glyph requests.[3] Leave it on; optionally point it at a nicer on-device CJK font. Setting
localIdeographFontFamily: false
restores served glyphs for CJK, which is much slower — only do it if you specifically need the served font's shapes.
javascript
const map = new maplibregl.Map({
// ...
localIdeographFontFamily: '"Noto Sans CJK SC", sans-serif' // optional; default is 'sans-serif'
});
Other non-Latin scripts (Arabic, Hebrew, Thai, …) — need real glyphs. does not apply here. Add the relevant Noto script font to the layer's
fallback list and serve its glyph PBFs (or rely on the GL JS ≥ 5.11.0 local fallback, which is environment-dependent — see the top of this section). Font names must match those the glyph server knows.
Devanagari, Khmer, and other scripts requiring ligatures/reordering — glyphs alone will not fix this. MapLibre maps each Unicode codepoint to one glyph with no shaping engine (no HarfBuzz/Raqm), so it cannot form the conjuncts and reordering these scripts require — serving the correct font's PBFs will not produce correct-looking text. There is currently no configuration fix; this is a known architectural limitation.[6]
json
{ "text-font": ["Noto Sans Regular", "Noto Sans Devanagari Regular"] }
Arabic and Hebrew additionally need the RTL text plugin for correct right-to-left shaping and ordering — glyph coverage alone is not enough. MapLibre GL JS does not handle RTL by default[2]:
javascript
import { setRTLTextPlugin } from 'maplibre-gl';
setRTLTextPlugin('https://unpkg.com/maplibre-gl/dist/maplibre-gl-rtl-text.js', null, true);
Call this before initializing the map.
Sprites: Icons and Markers
The style JSON's
value is a
base URL with no file extension (e.g.
https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite
, for testing purposes only, do not use in production); MapLibre appends
,
, and
variants itself. Symbol layers reference sprite images by ID with
; the value must exactly match an ID in the sprite JSON index or the icon is silently not rendered.
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
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 (GitHub Pages, Netlify, Vercel, S3, 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 [4], 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) [5].
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.
Creating your own icons
For a small number of custom icons,
and
can work without a full sprite pipeline. For larger reusable icon sets, generating a sprite remains the standard and more maintainable approach. [10]
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
from the sprite. Diagnose in this order:
- Confirm glyphs load. Probe the server for the exact names and expect HTTP 200. If they 200, the font is not the problem.
- Confirm the sprite carries the shield images. OpenMapTiles and OSM Liberty shield layers use
icon-image: "{network}_{ref_length}"
for known networks (e.g. , , ) and for generic refs. A missing icon is silently omitted, so grep the sprite JSON for those keys.
Not every sprite carries shields localized for the US, so grep the sprite JSON for the
keys before assuming they exist. Both the
demotiles.maplibre.org/styles/osm-bright-gl-style/sprite
and
openmaptiles.github.io/osm-bright-gl-style/sprite
sheets currently include
,
, and
(alongside the generic
–
), but a minimal or custom sprite may ship only the generic
. If yours lacks the shield images and your tiles populate
,
, and
(the OSM US OpenMapTiles tiles do), point
at one that has them — the
layers then resolve with no layer edits.
Layer Ordering
MapLibre renders layers in the order they appear in the style
array — first item is drawn first (bottom), last is drawn last (top). Getting this wrong is the most common cause of data layers obscuring basemap labels.
The injection pattern
When adding your own data to an existing basemap style at runtime, insert your layers
before the first symbol layer (find it with
map.getStyle().layers.find((l) => l.type === 'symbol')?.id
and pass it as the second argument of
) so your geometry renders under labels. Without that argument the layer goes above everything, including labels.
Canonical layer order for custom styles
When building a style from scratch, follow this ordering bottom to top:
- Raster imagery (if using satellite/aerial source)
- Hillshade layers (if any — see maplibre-terrain-patterns for configuration)
- Terrain fill (water, land, parks — polygon layers)
- Line layers (roads, boundaries, rivers)
- Your data polygon and line layers
- Symbol layers from the basemap (place labels, road labels)
- Your data symbol/label layers (if any)
Hillshade sits directly above raster imagery and below all vector layers, with sufficient transparency to allow the imagery to show through. If you add transparency to the imagery and layer it over the hillshade, the imagery will appear faded or washed out. Hillshade applied over vector layers will make line and fill colors look blotchy, blurry or muted.
Accessibility
MapLibre styles are rendered in the browser as a WebGL canvas. Accessibility considerations:
- Text contrast: WCAG 2.1 AA requires 4.5:1 for normal text, 3:1 for large.[9] White text on a halo satisfies this for most backgrounds — check the combined text+halo color, not the text alone, with a tool like the WebAIM Contrast Checker.
- Do not rely on color alone: use shape, size, or pattern in addition to hue.
- Minimum label size: prefer size stops that start at 10px even at low zoom.
- Screen readers and the WebGL canvas: MapLibre's canvas is not inherently accessible to screen readers. For accessible map experiences, provide an accessible alternative such as a data table or a text description of the map contents, and use maplibre-gl-accessibility for keyboard navigation and ARIA roles.
Related Skills
- maplibre-tile-sources — Setting up glyphs, sprites, and source configuration.
- maplibre-pmtiles-patterns — Serving imagery (raster) and terrain sources from PMTiles files.
- maplibre-terrain-patterns — Hillshade configuration, multi-pass techniques, 3D terrain, DEM sources.
References
- (MapLibre GL JS API)
- (MapLibre GL JS API) — required for correct Arabic/Hebrew shaping
- Use locally generated ideographs (MapLibre GL JS example) — default and CJK rendering behavior
- 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
- "About Text Rendering in MapLibre" — SDF glyph architecture, codepoint-to-glyph mapping, and why shaping-dependent scripts (Devanagari, Khmer) don't render correctly