Generating Style Variants from a Design Token File

Put every colour in one token file, reference tokens rather than hex values in the base style, and compile a variant per theme. A colour is then defined once, changing it changes every place it appears, and the compiler can assert contrast across the whole set before anything is published.

When to Use This

Two or more themes, or one theme shared with a wider design system. Below that threshold a base style with a colour override file is enough, and the token indirection adds a layer for nothing.

The moment it starts paying is when a brand colour appears in eight layers and someone asks to change it — with tokens that is one edit, and without it is eight edits and a review that has to catch the one that was missed.

Specification Detail

A token file is plain JSON with semantic names, resolved per theme:

json
{
  "light": {
    "surface":        "#fff7ef",
    "ink":            "#2a1a12",
    "ink-muted":      "#7a5a45",
    "road-fill":      "#ffffff",
    "road-casing":    "#e6c4a4",
    "water":          "#a9d6e5",
    "label-halo":     "#fff7ef"
  },
  "dark": {
    "surface":        "#16100c",
    "ink":            "#f8ece1",
    "ink-muted":      "#c6a98f",
    "road-fill":      "#3a2a20",
    "road-casing":    "#5a4535",
    "water":          "#1c3d4e",
    "label-halo":     "#16100c"
  }
}

And the base style references them by name, in the one place structure lives:

json
{
  "id": "road-primary",
  "type": "line",
  "source": "basemap",
  "source-layer": "roads",
  "filter": ["==", ["get", "class"], "primary"],
  "paint": {
    "line-color": "{token:road-fill}",
    "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 16, 6]
  }
}

Note what is not tokenised: the filter, the interpolation, the layer type. A token file that can express those has stopped being a token file and become a second style.

What the compiler reads and what it emitsA base style carrying structure and token references, a token file carrying colours per theme, and an environment file carrying URLs, compiled into one flat style per theme per environment.THREE INPUTSBase styleone fileEvery layer, in order, with token referenceswhere a colour would be. Never deployeddirectly.Token fileper themeSemantic colour names resolved per theme.The only file a designer needs to touch.Environmentper deploymentTile URLs, sprite and glyph hosts. No visualdifference at all.Compiled stylesN outputsOne flat style per theme per environment,content-hashed and immutable.
Three inputs, N outputs, and only the base defines structure. That constraint is what keeps the variants from diverging.

Production Command

python
import json
import hashlib
import re
from pathlib import Path

TOKEN_RE = re.compile(r"^\{token:([a-z0-9-]+)\}$")


def resolve(node, tokens: dict[str, str], path: str = ""):
    """Replace every {token:name} with its value, failing loudly on an unknown name."""
    if isinstance(node, str):
        m = TOKEN_RE.match(node)
        if not m:
            return node
        name = m.group(1)
        if name not in tokens:
            raise KeyError(f"unknown token {name!r} at {path}")
        return tokens[name]
    if isinstance(node, list):
        return [resolve(v, tokens, f"{path}[{i}]") for i, v in enumerate(node)]
    if isinstance(node, dict):
        return {k: resolve(v, tokens, f"{path}.{k}") for k, v in node.items()}
    return node


def compile_style(base: Path, tokens_file: Path, env: Path, theme: str) -> tuple[str, dict]:
    style = json.loads(base.read_text())
    tokens = json.loads(tokens_file.read_text())[theme]
    style = resolve(style, tokens)

    for key, value in json.loads(env.read_text()).items():
        if key == "sources":
            for source_id, url in value.items():
                style["sources"][source_id]["url"] = url
        else:
            style[key] = value

    style.setdefault("metadata", {})["vt:theme"] = theme
    blob = json.dumps(style, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode()).hexdigest()[:12], style


for theme in ("light", "dark"):
    digest, style = compile_style(
        Path("style/base.json"), Path("style/tokens.json"),
        Path("style/env.prod.json"), theme)
    out = Path(f"dist/style-{theme}.{digest}.json")
    out.write_text(json.dumps(style, indent=2))
    print(f"{theme}: {out.name}")

The KeyError on an unknown token is the point of the whole exercise. A typo in a token name fails the build rather than compiling to the literal string {token:road-fil}, which MapLibre would treat as an invalid colour and quietly ignore.

One compile pass, per themeThe base style is loaded, token references are resolved against the theme's palette, environment URLs are substituted, contrast is asserted, and the result is written under a content hash.COMPILELoad basestructure + tokensResolveper themeunknown token failsthe buildApply envurls onlyAssert contrastevery text/fill pairHash + writestyle-<theme>.<hash>.json
Deterministic by construction: the same three inputs always produce the same hash, which is what makes the output safe to mark immutable.

Asserting Contrast at Compile Time

Because every colour is a token and every theme resolves the same names, the pairs that need checking are enumerable. A label’s text colour and the fills it can sit above are a small matrix, and computing WCAG contrast over it is a dozen lines:

python
def luminance(hex_colour: str) -> float:
    r, g, b = (int(hex_colour[i:i + 2], 16) / 255 for i in (1, 3, 5))
    f = lambda c: c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
    return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)


def contrast(a: str, b: str) -> float:
    la, lb = sorted((luminance(a), luminance(b)))
    return (lb + 0.05) / (la + 0.05)


PAIRS = [("ink", "surface"), ("ink", "water"), ("ink-muted", "surface"),
         ("ink", "road-fill"), ("label-halo", "ink")]

for theme, palette in json.loads(Path("style/tokens.json").read_text()).items():
    for fg, bg in PAIRS:
        ratio = contrast(palette[fg], palette[bg])
        status = "ok " if ratio >= 4.5 else "FAIL"
        print(f"{status} {theme:5s} {fg} on {bg}: {ratio:.2f}:1")

Running this on every compile is what stops a dark variant shipping with labels nobody can read — the failure mode a dark-mode style reaches most often, because the light theme is the one everyone looks at.

Which style properties belong in the token fileSix style properties classified by whether they may be tokenised, with the reason for each.WHAT MAY BE A TOKENtokenise?becausefill-colorYesThe definition of athemetext-halo-colorYesContrast depends on thebackgroundbackground colourYesThe base canvasline-widthNoGeometry, identical inbothLayer filtersNoSame features in boththemesSource URLsNoEnvironment, not theme
One test decides every row: does it differ between themes? If not, tokenising it adds indirection and buys nothing.

Interaction Effects

With sprites. Icons are images, not tokens, so a themed map needs SDF icons tinted through icon-color — which can be a token — or a second sprite sheet. The first keeps one sheet and one source of truth; see sprite generation.

With a wider design system. If the tokens come from a system shared with the application, import them rather than copying. A map whose surface colour drifts from the page around it is the exact problem tokens exist to prevent.

With runtime switching. Compiled variants are separate documents, so switching themes is setStyle with the tile cache preserved — no tile is refetched, only the paint values change.

Performance Impact

Compilation is milliseconds and happens at build time; the runtime cost is zero, since the deployed style contains resolved colours with no indirection. The only runtime consideration is that each variant is its own document with its own cache entry — two themes means two style fetches over a reader’s lifetime, not two per session.

Common Mistakes

Tokenising non-colour properties. A token file that sets line-width or a filter has become a second style, and the two will diverge.

Silently falling back on an unknown token. Compiling {token:typo} through to the output produces an invalid colour that MapLibre ignores, leaving the layer at its spec default. Fail the build instead.

Checking contrast on the light theme only. The dark palette is the one that needs it, and it is the one nobody looks at daily.

Hand-editing a compiled style. The next compile overwrites it. Every change belongs in the base, the tokens or the environment file.

FAQ

Why not use CSS custom properties?

A MapLibre style is JSON consumed by WebGL, not CSS, so there is no cascade to resolve variables at runtime. The resolution has to happen somewhere, and build time is cheaper than every frame.

Can tokens reference other tokens?

They can, with a resolution pass that handles indirection, and it is worth keeping shallow. One level — semantic names pointing at a small palette — covers most needs; deeper chains make a colour hard to trace.

How many themes is this worth for?

Two justifies it. One does not. Beyond three or four it becomes indispensable, since the manual alternative scales as themes times layers.

Should the token file live with the style or with the design system?

With the design system if one exists, imported by the style build. That way a brand change reaches the map through the same mechanism as everything else.