Theme Inheritance Patterns for Vector Tile Map Styling
Maintaining multiple map variants — dark mode, high-contrast, regional palettes, seasonal campaigns — becomes unmanageable when each style is authored from scratch. Theme inheritance solves this by establishing a three-tier hierarchy: an immutable base style, environment-specific override files, and runtime attribute expressions. Every compiled variant shares one source of truth, so a color palette change propagates to all variants through a single CI run.
This technique sits inside the broader Map Styling & Layer Synchronization workflow and pairs tightly with Dynamic Attribute Mapping for runtime bindings and with Style Validation Workflows for pre-deployment correctness checks.
Prerequisites
| Requirement | Minimum version / state |
|---|---|
| Python | 3.9+ |
deepmerge |
1.1+ |
pydantic |
2.x |
jsonschema |
4.x |
| Base style | Schema-valid MapLibre GL v8 JSON |
| Tile pipeline | Consistent source-layer names across all target tilesets |
| CI/CD | Artifact store (S3, GCS, or Cloudflare R2) with versioned URL support |
The tile generation pipeline — Tippecanoe, Martin, or a PostGIS exporter — must emit stable layer names and attribute keys. A source-layer rename mid-pipeline silently breaks every override file referencing that layer.
Core Concept: Three-Tier Hierarchy
The three tiers map to distinct responsibilities:
| Tier | File | Deployed directly? | Key content |
|---|---|---|---|
| Base | base.style.json |
Never | Color palette, typography, layer ordering, fallback expressions |
| Environment | theme.dark.json, theme.hc.json, theme.eu.json |
Only after merge | Paint/layout deltas, visibility toggles, sprite swaps |
| Runtime | Generated ["interpolate"] / ["match"] expressions |
Injected at compile time | Attribute-driven paint values from Dynamic Attribute Mapping |
The base style is the compilation anchor; it never ships to a CDN endpoint in raw form. Environment overrides must be minimal — containing only the keys that differ from the base. Runtime expressions bridge the static compilation output with live tile attributes.
The diagram below shows how the three tiers feed the CI compiler and produce independently versioned CDN artifacts:
Step-by-Step Implementation
Step 1: Author the base style
The base style contains globally applicable defaults only. Keep it lean — every key here must make sense for all variants.
{
"version": 8,
"name": "base",
"metadata": {
"pipeline:role": "base",
"pipeline:version": "1.0.0"
},
"glyphs": "https://cdn.example.com/fonts/{fontstack}/{range}.pbf",
"sprite": "https://cdn.example.com/sprites/default",
"sources": {
"openmaptiles": {
"type": "vector",
"tiles": ["https://tiles.example.com/{z}/{x}/{y}.pbf"],
"minzoom": 0,
"maxzoom": 14
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": { "background-color": "#f8f4f0" }
},
{
"id": "water",
"type": "fill",
"source": "openmaptiles",
"source-layer": "water",
"paint": { "fill-color": "#a8d5e2" }
},
{
"id": "road-primary",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"filter": ["==", ["get", "class"], "primary"],
"paint": { "line-color": "#e8c87a", "line-width": 2 }
}
]
}
Verify: jsonschema-validate the file against the MapLibre v8 spec before committing. The MapLibre GL JSON Structure reference documents every required key.
Step 2: Write minimal environment overrides
Override files contain only the keys that differ. Arrays (like layers) use a by-id merge strategy in the compiler, so you only need to include layers whose paint/layout properties change.
// theme.dark.json
{
"name": "dark",
"metadata": { "pipeline:role": "override", "pipeline:variant": "dark" },
"layers": [
{
"id": "background",
"type": "background",
"paint": { "background-color": "#1a1a2e" }
},
{
"id": "water",
"type": "fill",
"source": "openmaptiles",
"source-layer": "water",
"paint": { "fill-color": "#16213e" }
},
{
"id": "road-primary",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"filter": ["==", ["get", "class"], "primary"],
"paint": { "line-color": "#e94560", "line-width": 2 }
}
]
}
Verify: The override file must be significantly smaller than the base. If it is approaching the same size, the base is not lean enough.
Step 3: Build the deep-merge compiler
Naive dict.update() flattens nested objects and reorders layers by positional index. Use a schema-aware merge that reconciles layers arrays by id field.
import json
import hashlib
from pathlib import Path
from typing import Any
def merge_layers(base_layers: list, override_layers: list) -> list:
"""Merge layers arrays by 'id', preserving base ordering."""
index = {layer["id"]: layer.copy() for layer in base_layers}
for ol in override_layers:
lid = ol.get("id")
if lid in index:
# Deep-merge paint and layout sub-dicts; scalar keys overwrite
for key in ("paint", "layout"):
if key in ol:
index[lid].setdefault(key, {}).update(ol[key])
# Non-dict keys overwrite directly
for key, val in ol.items():
if key not in ("paint", "layout"):
index[lid][key] = val
else:
# Append new layers from override at the end
index[lid] = ol.copy()
# Preserve base ordering, then append override-only layers
base_ids = [l["id"] for l in base_layers]
extra = [v for k, v in index.items() if k not in base_ids]
return [index[lid] for lid in base_ids] + extra
def deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base; arrays use by-id strategy for layers."""
result = base.copy()
for key, val in override.items():
if key == "layers" and key in result:
result[key] = merge_layers(result[key], val)
elif isinstance(val, dict) and isinstance(result.get(key), dict):
result[key] = deep_merge(result[key], val)
else:
result[key] = val
return result
def compile_theme(
base_path: Path,
override_path: Path,
output_dir: Path,
) -> Path:
base_style = json.loads(base_path.read_text())
override_style = json.loads(override_path.read_text())
merged = deep_merge(base_style, override_style)
# Deterministic content hash for cache-busting
content_hash = hashlib.sha256(
json.dumps(merged, sort_keys=True).encode()
).hexdigest()[:12]
variant = override_style.get("name", override_path.stem)
out_path = output_dir / f"{variant}-{content_hash}.json"
output_dir.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(merged, indent=2, sort_keys=True))
return out_path
Verify: Run a diff between two consecutive compiled outputs for the same variant. If the content hash changes without any source file changing, the merge is non-deterministic.
Step 4: Validate every compiled output
Run two validation passes before the artifact leaves CI:
- Schema compliance against the MapLibre v8 spec JSON
- Expression linting — verify every
["get", attr]reference exists in your tile schema
import jsonschema
import requests
_SCHEMA_URL = (
"https://raw.githubusercontent.com/maplibre/maplibre-style-spec"
"/main/src/reference/v8.json"
)
def validate_style(style_path: Path, schema_cache_path: Path) -> None:
"""Raises jsonschema.ValidationError on any spec violation."""
if not schema_cache_path.exists():
schema_cache_path.write_text(requests.get(_SCHEMA_URL, timeout=10).text)
schema = json.loads(schema_cache_path.read_text())
style = json.loads(style_path.read_text())
jsonschema.validate(instance=style, schema=schema)
def audit_get_refs(style: dict, known_attrs: set[str]) -> list[str]:
"""Return a list of attribute names referenced via ['get', ...] with no tile backing."""
missing = []
def walk(node: Any) -> None:
if isinstance(node, list) and len(node) >= 2 and node[0] == "get":
attr = node[1]
if isinstance(attr, str) and attr not in known_attrs:
missing.append(attr)
elif isinstance(node, dict):
for v in node.values():
walk(v)
elif isinstance(node, list):
for item in node:
walk(item)
walk(style)
return missing
The Style Validation Workflows guide covers plugging this step into GitHub Actions with schema caching to avoid network fetches on every CI run.
Step 5: Publish with content-hash URLs and correct headers
# Compile all variants
python3 compile_themes.py \
--base themes/base.style.json \
--overrides themes/theme.*.json \
--output dist/styles/
# Upload to Cloudflare R2 (or S3)
for f in dist/styles/*.json; do
aws s3 cp "$f" "s3://example-tiles/styles/$(basename $f)" \
--content-type "application/json" \
--cache-control "public, max-age=31536000, immutable"
done
# Update the short-lived manifest that clients poll
aws s3 cp dist/styles/manifest.json s3://example-tiles/styles/manifest.json \
--content-type "application/json" \
--cache-control "public, max-age=60, must-revalidate"
Verify: Fetch the manifest URL and confirm the ETag changes after an upload. Request any compiled style artifact and confirm the Cache-Control header includes immutable.
Why Forking Is the Failure Mode
Almost every team arrives at theme inheritance after trying the obvious thing first: copy the style, recolour the copy, ship both. It works for about two releases. The failure is not dramatic — nothing breaks — it is that the two files stop describing the same map.
The drift is always in the same direction. A layer is added to the light style during a feature change and not to the dark one, so dark-mode readers silently lose a layer. A zoom range is corrected in one file. A filter is tightened in the other. Six months later the two styles differ in forty places, of which perhaps six are intentional colour differences and the rest are accidents nobody can now distinguish from decisions.
Inheritance removes the possibility rather than the temptation. When structure exists in exactly one file, adding a layer adds it to every theme by construction, and the only thing a theme file can say is which colour a property takes. That constraint is worth enforcing mechanically: a compiler that rejects a theme override touching anything but a colour property will catch the first accidental structural override on the day it is written, rather than at the next visual review.
Testing Both Themes Rather Than One
A compiled output is only as good as the check that runs against it, and theme compilation introduces a specific risk: the light theme is the one everybody looks at. The dark variant is compiled from the same base and is therefore structurally correct, which makes it easy to assume it is visually correct too. It usually is not, because contrast is a property of a colour pair, and a palette that works against a pale background rarely inverts cleanly.
The pipeline should therefore validate every compiled output, not the base and one variant:
- Schema validation on each compiled style. Cheap, and catches an override that introduced a malformed value.
- Contrast assertions on each theme’s label-and-background pairs. Every text colour against every fill it can sit above, which for a basemap is a small matrix and an entirely mechanical check.
- A rendered screenshot per theme at two or three zooms. The only check that catches a layer that is technically visible and practically invisible, such as a road casing that has become the same value as the road it outlines.
Running all three on every compiled variant is what makes adding a fourth theme a non-event. Running them only on the theme the team looks at daily is what makes the second theme drift into a liability.
Optimization Knobs
| Parameter | Conservative | Aggressive | Trade-off |
|---|---|---|---|
| Override depth | Base → Environment (2 levels) | Base → Region → Campaign (3 levels) | Deeper trees are harder to debug; merge time is negligible |
| Array merge strategy | By id (recommended) |
Positional index | Positional scrambles layer z-order on any insertion |
| Manifest TTL | 60 s | 300 s | Lower TTL catches style rollouts faster; slightly higher CDN origin load |
| Schema cache TTL in CI | Pin to git-tracked local copy | Fetch from spec repo HEAD | Live fetch risks breaking builds on spec repo downtime |
Integration with Adjacent Pipeline Stages
Compiled theme artifacts slot into the broader pipeline at two points:
Upstream — tile generation: The source-layer names in override files must match the layer names emitted by Tippecanoe or Martin. If the tile pipeline uses --layer=transport but the style references source-layer: transportation, every road layer silently disappears. Coordinate renames through the same CI commit that updates the base style.
Downstream — CDN and tile server: Theme JSON files are static blobs. Serve them from the same CDN origin as .pmtiles or .mbtiles tile files so that a single CDN rule covers both. When updating MBTiles containers or converting to PMTiles for HTTP range-request delivery, increment both the tile URL version and the theme manifest in the same deployment. A mismatched source URL in the compiled style causes the renderer to request tiles from a stale or deleted endpoint.
Troubleshooting
| Symptom | Diagnosis | Fix |
|---|---|---|
| Layer disappears after dark-mode override | source-layer mismatch between compiled style and tile payload |
grep -r '"source-layer"' dist/styles/dark-*.json — compare against tippecanoe --layer= values |
| Layer z-order scrambles after adding a new override | Positional array merge used instead of by-id merge |
Switch to merge_layers() shown in Step 3; re-run compile |
| Compiled hash differs across identical runs | Non-deterministic key ordering in json.dumps |
Pass sort_keys=True to every json.dumps call in the pipeline |
| Schema validation passes locally but fails in CI | Different jsonschema versions or cached schema mismatch |
Pin jsonschema==4.21.1 in requirements.txt; commit the cached schema JSON |
| Stale style served to browser after deployment | CDN serving cached manifest or Cache-Control: immutable on manifest endpoint |
Set max-age=60, must-revalidate on manifest.json; verify with curl -I |
FAQ
Why not just keep two style files?
Because they drift. A layer added to one and not the other, a zoom range corrected in one — after six months the two differ in dozens of places and nobody can tell which differences were intended.
What may a theme file contain?
Colour properties only: paint colours, halos, the background layer. If a theme file names a layer type, a filter or a zoom range, the split is wrong and the themes will diverge.
How do I stop structural overrides creeping in?
Enforce it in the compiler. A build that rejects a theme override touching anything but a colour catches the first accidental structural change on the day it is written.
Do I need to validate every compiled variant?
Yes. They are structurally identical by construction and visually different by design, and contrast is a property of a colour pair — so the dark variant needs its own check.
Further Reading
Compiling Dark-Mode Tile Styles from a Base — generating light and dark variants from one base style by overriding only paint colors, with an assertion that every variant keeps identical layer ids and source-layer names so all stay schema-synchronized.
Related Pages
- MapLibre GL JSON Structure — the underlying spec that every compiled theme must satisfy, including layer ordering rules and expression syntax.
- Dynamic Attribute Mapping — generate runtime
["interpolate"]and["match"]expressions injected at the third inheritance tier. - Style Validation Workflows — CI pipeline steps for schema compliance, expression linting, and visual regression testing of compiled themes.