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" }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
What you get
What you get
Lazy loading without I/O
resolve returns the tokens or the list of documents it still needs. Only the documents of the selected contexts are requested, from disk, HTTP, an asset bundle or memory.
Read more →Themes as modifiers
Sets, modifiers with named contexts, nested resolvers and shallow overrides merge into one document, so a dark theme overrides a few palette tokens and every alias follows.
Read more →Typed projection
#[derive(DtcgTokens)] maps token paths onto struct fields with Option fields, nested structs and your own conversions. Typed models cover all 13 DTCG types.
Read more →Strict, named errors
Wrong units, dangling aliases, type mismatches and cycles fail with the document and the token path, not as a wrong color on screen.
Read more →Built for editors
Re-resolve only the tokens an edit changed, inspect where every source came from, preview unsaved changes and list every file reference.
Read more →Passes the official suite
All 250 fixtures of the DTCG 2025.10 test suite pass, with bounded work on untrusted input and a SHA-256 fingerprint of every resolution.
Read more →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);
#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.