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

What a theme file is allowed to containA theme override file limited to paint colour properties, halo colours, the background layer and the light setting, with structure explicitly excluded.THE CONTRACTpaint coloursallowedfill, line, text, iconcolourhalo + outlineallowedcontrast against the newbackgroundbackground layerallowedthe base canvas colourstructurerejectedlayers, filters, order,zoom ranges
Enforce this in the compiler, not by convention. A theme that can override filters will eventually do so, and the two themes will drift apart.

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:

json
{
  "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:

json
{ "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:

python
# 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:

bash
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

Switching themes without refetching a single tileThe reader toggles the theme, the application loads the compiled dark style, MapLibre swaps the style while keeping the tile cache, and the same tiles are repainted.ReaderApplicationMapLibretoggle to darksetStyle(dark.json, { diff: true })reuse the tile cache — no refetchsame source, same tilesrepainted in the new palette
The tiles never change — this is the whole argument for vector tiles in one interaction. Only the paint values are swapped.

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

What to assert about a dark palette before it shipsFive contrast assertions covering label text, halos, road casings, selection highlights and the palette applied to icons.CONTRAST GATESLabel text against every fill it can sit onwater, landcover and building fills all differHalo colour actually separates text from the basea dark halo on a dark fill does nothingRoad casing distinguishable from the road fillthe pair that collapses first in a dark paletteSelection highlight visible against both themesa highlight tuned for light usually vanishes in darkIcons re-tinted, not merely reuseda sprite sheet drawn for light backgrounds stays light
Contrast on a map is not one ratio — every label sits on a different background depending on what is under it, which is why halos carry most of the burden.

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

FAQ

Do the tiles change between themes?

No. That is the whole argument for vector tiles in one interaction: switching theme swaps paint values while the tile cache is preserved, so not a single tile is refetched.

What breaks first in a dark palette?

Road casing against road fill, and label halos. Both are contrast pairs tuned for a pale background, and both collapse when the background inverts.

Can I reuse the light theme’s sprite?

Only if the icons are SDF and tinted through icon-color. A full-colour sheet drawn for light backgrounds stays light, and the icons will be invisible.

Should the dark theme follow the OS preference?

Follow it on first visit and persist an explicit choice afterwards. Readers who have chosen expect the choice to stick across pages.