Compiling Dark-Mode Tile Styles from a Base
Keep one base style whose colors are named tokens, then compile each theme by overriding only paint color properties — never layer structure, source-layer names, filters, or sources. Because every variant is the same base with a different color table applied, light, dark, and high-contrast stay automatically synchronized with the tile schema: there is exactly one place where a layer can reference a tile attribute, and all variants inherit it.
When to use this
Reach for a compiled base whenever you ship more than one visual theme against the same tiles — light and dark being the universal pair, often joined by high-contrast or a print variant. Hand-forking a full style.json per theme works for exactly one release; by the second tile-schema change you have three styles drifting out of sync and a dark mode referencing a layer the light mode already renamed. Compiling from a base makes structure single-sourced and reduces each theme to the only thing that legitimately differs between them: color.
Specification detail: tokens and the override contract
The base declares colors indirectly through a metadata token table, and each layer’s paint reads a token name that the compiler resolves at build time.
| Concept | Where it lives | Varies per theme? |
|---|---|---|
| Color token table | metadata["tokens"] in the base |
Yes — this is the only thing a variant overlay changes |
Layer id |
layers[].id |
No — must be identical across all variants |
source / source-layer |
layers[] |
No — the tile contract; identical across variants |
filter |
layers[] |
No — filters select features, not appearance |
layout (visibility, text-field, sort key) |
layers[] |
No — structural; a hidden layer is hidden in every theme |
paint non-color props (*-width, *-opacity, *-radius) |
layers[] |
No — geometry weight is theme-independent |
paint color props (*-color) |
layers[], via token |
Resolved from the active token table |
The override contract is deliberately narrow: a variant overlay is a flat map of token name to color, and the compiler may only write paint.*-color properties. Everything else is copied from the base byte-for-byte. This is what guarantees schema agreement — because no variant can touch a source-layer or a ["get", key], no variant can disagree with the tiles about what those are. It is the same discipline the parent theme inheritance patterns guide applies at the whole-file level, scoped down here to the single dark/light color axis.
Production command block
The base carries a token table and layers that reference tokens by name:
{
"version": 8,
"name": "base",
"metadata": {
"tokens": {
"bg": "#f8f4f0", "water": "#a8d5e2",
"road": "#e8c87a", "label": "#333333"
}
},
"sources": { "basemap": { "type": "vector", "url": "https://tiles.example.com/city.json" } },
"layers": [
{ "id": "background", "type": "background",
"paint": { "background-color": "@bg" } },
{ "id": "water", "type": "fill", "source": "basemap", "source-layer": "water",
"paint": { "fill-color": "@water" } },
{ "id": "road-primary", "type": "line", "source": "basemap", "source-layer": "transportation",
"filter": ["==", ["get", "class"], "primary"],
"paint": { "line-color": "@road", "line-width": 2 } }
]
}
The dark overlay is only the tokens that change — never a layer:
{ "bg": "#12121a", "water": "#16213e", "road": "#c9962f", "label": "#e6e6e6" }
The compiler resolves @token references and refuses to touch anything but paint colors, then asserts structural parity before writing the variant:
# compile_variant.py
import json, sys
from pathlib import Path
def resolve_colors(layer: dict, tokens: dict) -> dict:
"""Return a copy of layer with @token paint colors resolved. Structure untouched."""
out = json.loads(json.dumps(layer)) # deep copy
paint = out.get("paint", {})
for prop, val in list(paint.items()):
if prop.endswith("-color") and isinstance(val, str) and val.startswith("@"):
token = val[1:]
if token not in tokens:
sys.exit(f"ERROR layer '{out['id']}': unknown color token '{val}'")
paint[prop] = tokens[token]
return out
def structural_key(layer: dict) -> tuple:
"""Everything a variant must NOT change."""
return (layer.get("id"), layer.get("source"),
layer.get("source-layer"), json.dumps(layer.get("filter")))
def compile_variant(base: dict, overlay: dict, name: str) -> dict:
tokens = {**base["metadata"]["tokens"], **overlay} # overlay wins per token
variant = json.loads(json.dumps(base))
variant["name"] = name
variant["layers"] = [resolve_colors(l, tokens) for l in base["layers"]]
# Parity gate: base and variant must be structurally identical
for b, v in zip(base["layers"], variant["layers"]):
if structural_key(b) != structural_key(v):
sys.exit(f"ERROR structural drift in layer '{b.get('id')}'")
if len(base["layers"]) != len(variant["layers"]):
sys.exit("ERROR layer count differs between base and variant")
return variant
if __name__ == "__main__":
base = json.loads(Path("styles/base.json").read_text())
overlay = json.loads(Path("styles/tokens.dark.json").read_text())
out = compile_variant(base, overlay, "dark")
Path("dist/style.dark.json").write_text(json.dumps(out, indent=2))
print("OK wrote dist/style.dark.json")
Run it per theme:
python compile_variant.py # base.json + tokens.dark.json -> style.dark.json
The light variant is simply the base tokens with no overlay, so light and dark are produced by the same code path and can never diverge structurally — the parity gate exits nonzero the moment a layer id or source-layer differs.
Interaction effects
Every variant inherits one schema contract. Because layers exist only in the base, the style-in-CI validation gate needs to check source-layer and ["get", key] agreement against the tiles just once, on the base — the variants are structurally identical by construction, so they cannot introduce a new mismatch. Still, run the spec validate() on each compiled output to catch a bad color value in an overlay.
Renamed layers are fixed in one place. When the tile build renames a source-layer, you update the base and recompile; dark, light, and high-contrast all pick up the fix in the same run. This is the opposite of forked styles, where a rename must be hand-applied to each file and the dark variant is the one everyone forgets — the exact failure mode covered in layer filter synchronization.
Pin the base version. Compile against a specific base commit or metadata version string and record it in each variant’s metadata, so a variant on the CDN can always be traced to the base and tile schema it was built from. Coordinate multi-source bases with the layout rules in structuring MapLibre styles for multi-source tiles — the token approach extends unchanged across sources, since tokens are global and layers still name their own source.
Performance impact
Runtime performance is not the concern here — a compiled dark style renders identically to a hand-written one, because the output is a normal style.json with literal colors. The payoff is build correctness and maintenance: one base to edit instead of N drifting files, and a mechanical guarantee that themes never disagree about structure. Compilation itself is a sub-second JSON transform, so it belongs in the same CI job that validates and publishes the styles.
Common mistakes
1. Forking full styles so they drift
Symptom: Dark mode is missing a layer that light mode added last sprint, or renders an old road color long after the palette changed.
Cause: Each theme is a separate hand-maintained style.json; an edit to one is not mirrored to the others.
Fix: Collapse to a single base plus per-theme token overlays and compile. Delete the forked files once the compiled outputs match; from then on, structure lives in exactly one place.
2. Overriding structure, not just color
Symptom: A variant overlay grows to include whole layers entries and starts changing a filter or source-layer “just for dark mode.”
Cause: Treating the overlay as a general patch file instead of a color table.
Fix: Keep overlays to token-to-color maps only, and let the parity gate enforce it — structural drift in layer '…' fails the build the instant a variant changes anything but a paint color. If a layer genuinely must differ structurally between themes, that belongs in the base with a layout.visibility toggle, not in a color overlay.
3. Dark variant referencing a layer the base already fixed
Symptom: After a source-layer rename, light mode works but dark mode renders a blank basemap.
Cause: A leftover forked dark file still pointing at the old layer name.
Fix: Recompile every variant from the corrected base so none can lag, and add the schema-agreement check from validating styles in CI as a required gate. A stale dark file cannot survive a build that only ever emits compiled variants.
4. Contrast failures in the dark palette
Symptom: Dark-mode labels or thin roads are legible on your monitor but vanish for users, failing accessibility review.
Cause: Dark tokens were eyeballed rather than checked against a contrast target.
Fix: Validate the token table against a WCAG contrast ratio (label vs background ≥ 4.5:1) in the same compile step, and fail the build on any token pair below threshold — contrast is a property of the color table, so it is checkable exactly where the tokens are resolved.
Parent: Theme Inheritance Patterns
Related
- Validating Styles in CI with the GL Style Spec — the schema-agreement gate that confirms the base (and therefore every variant) matches the tiles.
- Structuring MapLibre Styles for Multi-Source Tiles — how the token base extends when the map draws from several tilesets at once.
- Layer Filter Synchronization with Tile Attributes — why a single-sourced structure keeps a renamed
source-layerfrom silently breaking one theme but not another.