Design tokens for Rust

Design tokens in, typed Rust out.

dxn_dtcg reads DTCG 2025.10 token files and resolver documents, applies the theme the user picked, resolves aliases, validates every value against its type and hands you plain Rust structs. It does no I/O: it tells you which documents it needs, so the same code runs in a desktop app, on a server, in a build script or in WebAssembly.

[dependencies]
dxn_dtcg = { git = "https://github.com/dXnSix/dXn.Dtcg", tag = "v1.1.0" }
main.rs
use dxn_dtcg::values::{Color, Dimension};
use dxn_dtcg::{DtcgRuntime, DtcgTokens, Resolution};

#[derive(DtcgTokens)]
#[dtcg(root = "button")]
struct Button {
    background: Color,
    #[dtcg(rename = "paddingX")]
    padding_x: Dimension,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut runtime = DtcgRuntime::default();
    runtime.set_root("theme.resolver.json")?;
    let button = loop {
        match runtime.project::<Button>(&[("theme", "dark")])? {
            Resolution::Ready(button) => break button,
            Resolution::Missing(requests) => for request in requests {
                let json = std::fs::read_to_string(format!("design/{}", request.resource_id))?;
                runtime.provide_requested_json(&request, json)?;
            },
        }
    };
    println!("{}px", button.padding_x.to_px(16.0));
    Ok(())
}
Spec
DTCG 2025.10
Official fixtures
250 of 250 pass
Rust
1.97+
I/O
none
Default build
no unsafe, no C
Targets
native and wasm32
License
MIT OR Apache-2.0

Quick tour

#Switch themes by changing a selection

A resolver document lists token sources and declares modifiers. Pick a context and every source for it merges into one document; aliases resolve last, so surface.background follows the override.

let mut runtime = DtcgRuntime::from_json("theme.resolver.json", RESOLVER)?;
runtime.provide_json_batch([("base.tokens.json", BASE), ("dark.tokens.json", DARK)])?;

let light = runtime.resolve(&[])?.ready().expect("all documents provided");
let dark = runtime.resolve(&[("theme", "dark")])?.ready().expect("all documents provided");

assert_ne!(light.fingerprint(), dark.fingerprint());
assert_eq!(dark.get("surface.background").unwrap().value["components"][0], 0.1);

See Resolvers and modifiers.

#Load only what the selection needs

The runtime never opens a file. A missing document is a value, not an error: provide it and resolve again. Light mode never reads dark.json.

loop {
    match runtime.resolve(&selection)? {
        Resolution::Ready(tokens) => return Ok(tokens),
        Resolution::Missing(requests) => for request in requests {
            let text = my_loader(request.resource_id.as_str())?; // disk, HTTP, assets, tests
            runtime.provide_requested_json(&request, text)?;
        },
    }
}

See Loading documents for file system, async HTTP and embedded loaders, resource ids and path safety.

#Get structs, not JSON

Fields read tokens by path. Option fields tolerate absent tokens, nested reuses one shape at several roots, and with converts into types you do not own.

#[derive(DtcgTokens)]
#[dtcg(root = "card")]
struct Card {
    radius: Dimension,
    #[dtcg(rename = "fadeIn")]
    fade_in: Duration,
    #[dtcg(path = "shadow.raised")]
    shadow: Option<Shadow>,
}

See Typed projection.

#Errors that name the token

Validation runs on the resolved value, so bad data fails at load time with the document and token path.

token `spacing.md` in `tokens.json`: resolved value does not match DTCG type `dimension`
token `heading` in `tokens.json`: reference type mismatch: expected fontWeight, found number
modifier `theme` context `blue`: selected modifier context is unknown; allowed contexts: light, dark

See Errors and Troubleshooting.

#Edit without re-resolving everything

Replace one document and get back only the tokens the edit can have changed, then patch your snapshot. A failed edit rolls the document back.

let affected = runtime
    .replace_json_and_resolve_affected("tokens.json", edited, &[], &[])?
    .ready()
    .expect("document is loaded");
snapshot.apply_affected(&affected); // equals a full resolve (see expressions for module state)

See Incremental resolution and Source inspection.

#Compute tokens with expressions (opt-in)

With the expressions feature and RuntimeOptions::with_expressions, a token can compute its value from other tokens with a JavaScript or TypeScript expression stored in its $extensions. The $value stays a valid DTCG snapshot, so every other tool still reads the document, and without the option the extension is opaque metadata.

{
  "accent": { "$type": "color", "$value": { "colorSpace": "srgb", "components": [0.2, 0.4, 0.9] } },
  "accent-hover": {
    "$type": "color",
    "$value": { "colorSpace": "srgb", "components": [0.2, 0.4, 0.9] },
    "$extensions": { "dxn.dtcg.expressions": {
      "imports": { "dxn.dtcg/color": ["lighten"] },
      "inputs": { "accent": "{accent}" },
      "js": "lighten(accent, 0.08)"
    } }
  }
}

Expressions run in a sandboxed QuickJS engine with no clock or randomness and deterministic step, memory and stack limits, import ES modules from the host or from token documents, and call Rust functions. The feature compiles QuickJS from C and adds unsafe code around it; the default build stays free of both, and the color conversions on values::Color need no feature.

See Expressions and Platforms and versions.