A TypeScript build system for processing DTCG 2025.10 design tokens. Dispersa loads resolver documents, resolves references and modifiers, applies filters and transforms, then renders output to CSS, JSON, JS/TS or custom modules.
- DTCG 2025.10 compliant -- full support for the resolver and token format specifications
- Multiple outputs -- CSS custom properties, JSON, JS/TS modules
- Extensible pipeline -- custom preprocessors, filters, transforms, and renderers
- Schema validation -- AJV runtime validation with schema-generated TypeScript types
- In-memory mode -- use without the filesystem for build tools, APIs, and testing
- CLI -- config-first workflow with auto-discovery
Standard DTCG types: color, dimension, fontFamily, fontWeight, duration, cubicBezier, number
Composite types: shadow, typography, border, strokeStyle, transition, gradient
pnpm add dispersaDefine tokens inline and build CSS -- no files needed:
import type { ResolverDocument } from 'dispersa'
import { Dispersa, css } from 'dispersa'
import { colorToHex, nameKebabCase } from 'dispersa/transforms'
const resolver: ResolverDocument = {
version: '2025.10',
sets: {
base: {
sources: [
{
color: {
brand: {
primary: {
$type: 'color',
$value: { colorSpace: 'srgb', components: [0, 0.4, 0.8] },
},
},
neutral: {
white: {
$type: 'color',
$value: { colorSpace: 'srgb', components: [1, 1, 1] },
},
black: {
$type: 'color',
$value: { colorSpace: 'srgb', components: [0, 0, 0] },
},
},
},
spacing: {
small: { $type: 'dimension', $value: { value: 8, unit: 'px' } },
medium: { $type: 'dimension', $value: { value: 16, unit: 'px' } },
},
},
],
},
},
modifiers: {
theme: {
default: 'light',
contexts: {
light: [
{
semantic: {
background: { $type: 'color', $value: '{color.neutral.white}' },
text: { $type: 'color', $value: '{color.neutral.black}' },
},
},
],
dark: [
{
semantic: {
background: { $type: 'color', $value: '{color.neutral.black}' },
text: { $type: 'color', $value: '{color.neutral.white}' },
},
},
],
},
},
},
resolutionOrder: [{ $ref: '#/sets/base' }, { $ref: '#/modifiers/theme' }],
}
const dispersa = new Dispersa({ resolver })
const result = await dispersa.build({
outputs: [
css({
name: 'css',
preset: 'bundle',
selector: ':root',
transforms: [nameKebabCase(), colorToHex()],
}),
],
})
for (const output of result.outputs) {
console.log(output.content)
}For file-based tokens, define JSON files and reference them with
$refin your resolver document. See thebasicexample for a complete setup.
Dispersa ships four builder functions. Each returns an OutputConfig that can be passed to build().
Renders CSS custom properties.
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
-- | Unique output identifier |
file |
string | function |
-- | Output path (supports {modifier} patterns) |
preset |
'bundle' | 'standalone' | 'modifier' |
'bundle' |
Output preset |
selector |
string | SelectorFunction |
':root' |
CSS selector |
mediaQuery |
string | MediaQueryFunction |
-- | Media query wrapper |
preserveReferences |
boolean |
false |
Emit var() references for aliases |
minify |
boolean |
false |
Minify output |
transforms |
Transform[] |
-- | Per-output transforms |
filters |
Filter[] |
-- | Per-output filters |
Renders JSON output.
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
-- | Unique output identifier |
file |
string | function |
-- | Output path (supports {modifier} patterns) |
preset |
'bundle' | 'standalone' |
'standalone' |
Output preset |
structure |
'flat' | 'nested' |
-- | Token structure in output |
includeMetadata |
boolean |
-- | Include DTCG metadata fields |
minify |
boolean |
-- | Minify output |
transforms |
Transform[] |
-- | Per-output transforms |
filters |
Filter[] |
-- | Per-output filters |
Renders JavaScript/TypeScript modules.
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
-- | Unique output identifier |
file |
string | function |
-- | Output path (supports {modifier} patterns) |
preset |
'bundle' | 'standalone' |
'standalone' |
Output preset |
structure |
'flat' | 'nested' |
-- | Token structure in output |
moduleName |
string |
-- | Module name for exports |
generateHelper |
boolean |
-- | Generate token lookup helper (bundle mode) |
minify |
boolean |
-- | Minify output |
transforms |
Transform[] |
-- | Per-output transforms |
filters |
Filter[] |
-- | Per-output filters |
Presets control how modifier permutations are packaged into files.
standalone -- each permutation produces its own complete file. Use pattern-based filenames to distinguish them:
css({
name: 'css',
file: 'tokens-{theme}.css',
preset: 'standalone',
selector: ':root',
})
// -> tokens-light.css, tokens-dark.css (each with all tokens)bundle -- all permutations are bundled into a single file with format-specific grouping (CSS selectors, JSON keys, JS named exports):
css({
name: 'css',
file: 'tokens.css',
preset: 'bundle',
selector: ':root',
})
// -> tokens.css with :root { ... } and [data-theme="dark"] { ... }modifier -- CSS-only preset that emits only the tokens that differ per modifier context, not the full set:
css({
name: 'css',
file: 'tokens.css',
preset: 'modifier',
selector: (modifierName, context, isBase) => {
if (isBase) return ':root'
return `[data-${modifierName}="${context}"]`
},
})Import from dispersa/transforms. All transforms are factory functions that return a Transform object.
| Factory | Output |
|---|---|
colorToHex() |
#rrggbb / #rrggbbaa |
colorToRgb() |
rgb() / rgba() |
colorToHsl() |
hsl() / hsla() |
colorToOklch() |
oklch() |
colorToOklab() |
oklab() |
colorToLch() |
lch() |
colorToLab() |
lab() |
colorToHwb() |
hwb() |
colorToColorFunction() |
CSS color() function |
| Factory | Output |
|---|---|
dimensionToPx() |
"16px" |
dimensionToRem() |
"1rem" |
dimensionToUnitless() |
16 (numeric) |
| Factory | Output |
|---|---|
nameKebabCase() |
color-brand-primary |
nameCamelCase() |
colorBrandPrimary |
nameSnakeCase() |
color_brand_primary |
namePascalCase() |
ColorBrandPrimary |
nameConstantCase() |
COLOR_BRAND_PRIMARY |
nameCssVar() |
--color-brand-primary |
namePrefix(prefix) |
ds-color-brand-primary |
nameSuffix(suffix) |
color-brand-primary-token |
| Factory | Output |
|---|---|
fontWeightToNumber() |
400, 700, etc. |
durationToMs() |
"200ms" |
durationToSeconds() |
"0.2s" |
Import from dispersa/filters. All filters are factory functions that return a Filter object.
| Factory | Description |
|---|---|
byType(type) |
Include tokens matching the given $type |
byPath(pattern) |
Include tokens whose path matches a string or RegExp |
isAlias() |
Include only alias tokens (tokens referencing other tokens) |
isBase() |
Include only base tokens (tokens with direct values) |
import { byType, isAlias } from 'dispersa/filters'
css({
name: 'colors-only',
file: 'colors.css',
preset: 'bundle',
filters: [byType('color')],
transforms: [nameKebabCase(), colorToHex()],
})
css({
name: 'semantic-only',
file: 'semantic.css',
preset: 'modifier',
filters: [isAlias()],
transforms: [nameKebabCase(), colorToHex()],
})A Transform has an optional matcher (to scope which tokens it applies to) and a transform function:
import type { Transform } from 'dispersa'
const addPrefix: Transform = {
matcher: (token) => token.$type === 'color',
transform: (token) => ({
...token,
name: `brand-${token.name}`,
}),
}A Filter has a single filter function that returns true to keep a token:
import type { Filter } from 'dispersa'
const excludeDeprecated: Filter = {
filter: (token) => !token.$deprecated,
}A Preprocessor transforms raw token objects before parsing:
import type { Preprocessor } from 'dispersa'
const stripMetadata: Preprocessor = {
name: 'strip-metadata',
preprocess: (rawTokens) => {
const { _metadata, ...tokens } = rawTokens
return tokens
},
}
await dispersa.build({
preprocessors: [stripMetadata],
outputs: [
/* ... */
],
})Use defineRenderer<T>() to create type-safe custom renderers. The generic parameter gives you autocomplete and type-checking on both context and options inside format():
import { defineRenderer, outputTree } from 'dispersa'
import type { RenderContext } from 'dispersa'
// 1. Define your renderer-specific options
type SwiftUIOptions = {
structName?: string
accessLevel?: 'public' | 'internal'
}
// 2. Create the renderer with defineRenderer<T>()
const swiftUIRenderer = defineRenderer<SwiftUIOptions>({
format(context, options) {
const structName = options?.structName ?? 'DesignTokens'
const access = options?.accessLevel ?? 'public'
const tokens = context.permutations[0]?.tokens ?? {}
const props = Object.entries(tokens)
.map(([name, token]) => ` ${access} static let ${name} = ${JSON.stringify(token.$value)}`)
.join('\n')
return `import SwiftUI\n\n${access} struct ${structName} {\n${props}\n}\n`
},
})
// 3. Use it in your build config
await dispersa.build({
outputs: [
{
name: 'swift',
renderer: swiftUIRenderer,
file: 'DesignTokens.swift',
options: { structName: 'AppTokens', accessLevel: 'public' },
transforms: [nameCamelCase()],
},
],
})Every renderer receives a RenderContext with these fields:
| Field | Type | Description |
|---|---|---|
permutations |
{ tokens, modifierInputs }[] |
Resolved tokens for each permutation (theme/platform combo) |
output |
OutputConfig |
The current output configuration (name, file, options, transforms, filters) |
resolver |
ResolverDocument |
The resolved DTCG resolver document |
meta |
RenderMeta |
Modifier metadata: dimensions (e.g. ['theme', 'platform']), defaults, basePermutation |
buildPath |
string | undefined |
Output directory (undefined in in-memory mode) |
When your renderer needs to produce multiple files, return an OutputTree instead of a string:
import { defineRenderer, outputTree } from 'dispersa'
const multiFileRenderer = defineRenderer({
format(context) {
const files: Record<string, string> = {}
for (const { tokens, modifierInputs } of context.permutations) {
const content = Object.entries(tokens)
.map(([name, token]) => `${name}: ${JSON.stringify(token.$value)}`)
.join('\n')
const key = Object.values(modifierInputs).join('-') || 'default'
files[`tokens-${key}.yaml`] = content
}
return outputTree(files)
},
})The built-in renderers support three presets that control how permutations are handled:
| Preset | Behavior | Use case |
|---|---|---|
bundle |
All permutations in one file (e.g. CSS cascade with :root + [data-theme]) |
Single-file delivery |
standalone |
One file per permutation (e.g. tokens-light.css, tokens-dark.css) |
Platform-specific builds |
modifier |
Only the diff between a permutation and the base | Overlay/patch files |
Custom renderers can use context.meta.basePermutation to determine which permutation is the base.
Each OutputConfig (returned by builders like css() or constructed manually) bundles transforms, filters, and a renderer together. Global transforms/filters from BuildConfig are applied first, then per-output transforms/filters:
await dispersa.build({
// Global: applied to ALL outputs
transforms: [nameKebabCase()],
filters: [byType('color')],
outputs: [
css({
name: 'css',
preset: 'bundle',
// Per-output: applied AFTER global transforms
transforms: [colorToHex()],
}),
{
name: 'swift',
renderer: swiftUIRenderer,
// Per-output: applied AFTER global transforms
transforms: [nameCamelCase()],
},
],
})The CSS builder accepts functions for selector and mediaQuery, giving full control over how rules are generated per modifier context:
css({
name: 'css',
file: 'tokens.css',
preset: 'bundle',
selector: (modifierName, context, isBase, allInputs) => {
if (isBase) return ':root'
return `[data-${modifierName}="${context}"]`
},
mediaQuery: (modifierName, context, isBase) => {
if (modifierName === 'platform' && context === 'mobile') {
return '(max-width: 768px)'
}
return ''
},
})The function signature for both is:
;(
modifierName: string,
context: string,
isBase: boolean,
allModifierInputs: Record<string, string>,
) => stringDispersa supports two reference mechanisms:
Aliases ({token.name}) reference another token's value within $value:
{
"color": {
"primary": {
"$type": "color",
"$value": { "colorSpace": "srgb", "components": [0, 0.4, 0.8] }
},
"action": {
"$type": "color",
"$value": "{color.primary}"
}
}
}JSON Pointer $ref references files, resolver sets, or property-level values:
{
"colors": {
"blue": {
"$type": "color",
"$value": { "colorSpace": "srgb", "components": [0.2, 0.4, 0.9] }
},
"primary": {
"$type": "color",
"$ref": "#/colors/blue/$value"
}
}
}Token-level $ref preserves the token shape and resolves into $value. When $type is missing on an alias or $ref token, it is inferred from the referenced token.
Dispersa can run entirely without the filesystem. Pass a ResolverDocument object directly and omit buildPath to get output content in memory:
import type { ResolverDocument } from 'dispersa'
import { Dispersa, css } from 'dispersa'
import { colorToHex, nameKebabCase } from 'dispersa/transforms'
const resolver: ResolverDocument = {
version: '2025.10',
sets: {
base: {
sources: [
{
color: {
primary: {
$type: 'color',
$value: { colorSpace: 'srgb', components: [0, 0.4, 0.8] },
},
},
},
],
},
},
resolutionOrder: [{ $ref: '#/sets/base' }],
}
const dispersa = new Dispersa({ resolver })
const result = await dispersa.build({
outputs: [
css({
name: 'css',
preset: 'bundle',
selector: ':root',
transforms: [nameKebabCase(), colorToHex()],
}),
],
})
// Access generated content directly
for (const output of result.outputs) {
console.log(output.content)
}build()returns aBuildResultobject. It never throws.buildOrThrow()is the fail-fast variant that throws on invalid config, resolver errors, or build failures.
type BuildResult = {
success: boolean
outputs: { name: string; path?: string; content: string }[]
errors?: BuildError[]
}
type BuildError = {
message: string
code: ErrorCode
path?: string // file path (for FILE_OPERATION errors)
tokenPath?: string // token path (for TOKEN_REFERENCE, CIRCULAR_REFERENCE errors)
severity: 'error' | 'warning'
suggestions?: string[] // e.g. similar token names for TOKEN_REFERENCE errors
}ErrorCode is a union of all failure types:
| Code | Description |
|---|---|
TOKEN_REFERENCE |
Unresolved alias reference ({token.name}) |
CIRCULAR_REFERENCE |
Circular alias chain detected |
VALIDATION |
Schema or structural validation failure |
COLOR_PARSE |
Invalid color value |
DIMENSION_FORMAT |
Invalid dimension value |
FILE_OPERATION |
File read/write failure |
CONFIGURATION |
Invalid build or renderer configuration |
BASE_PERMUTATION |
Missing base permutation for bundle mode |
MODIFIER |
Invalid modifier input or context |
UNKNOWN |
Catch-all for unexpected errors |
Both BuildConfig.hooks (global) and OutputConfig.hooks (per-output) accept the same LifecycleHooks type. Global hooks fire once per build; per-output hooks fire in the context of each output.
await dispersa.build({
outputs: [
css({
name: 'css',
preset: 'bundle',
hooks: {
onBuildStart: ({ config }) => {
console.log(`[css] starting...`)
},
onBuildEnd: (result) => {
console.log(`[css] ${result.success ? 'done' : 'failed'}`)
},
},
}),
],
hooks: {
onBuildStart: ({ config }) => {
console.log(`Building ${config.outputs.length} output(s)...`)
},
onBuildEnd: (result) => {
if (result.success) {
console.log(`Build succeeded: ${result.outputs.length} file(s)`)
} else {
console.error(`Build failed: ${result.errors?.length} error(s)`)
}
},
},
})Execution order:
| # | Hook | Scope | When it fires |
|---|---|---|---|
| 1 | onBuildStart |
Global | Before permutation resolution |
| 2 | onBuildStart |
Per-output | Before each output is processed |
| 3 | onBuildEnd |
Per-output | After each output finishes (success or failure) |
| 4 | onBuildEnd |
Global | After all outputs complete (success or failure) |
All hooks support both sync and async functions.
Dispersa ships a CLI package (dispersa-cli) with a config-first workflow.
pnpm add dispersa-clidispersa build
dispersa build --config ./dispersa.config.tsThe CLI auto-discovers config files named dispersa.config.(ts|js|mts|mjs|cts|cjs). Use defineConfig for type safety:
// dispersa.config.ts
import { defineConfig } from 'dispersa-cli'
import { css, json } from 'dispersa'
import { colorToHex, nameKebabCase } from 'dispersa/transforms'
export default defineConfig({
resolver: './tokens.resolver.json',
buildPath: './dist',
outputs: [
css({
name: 'css',
file: 'tokens.css',
preset: 'bundle',
selector: ':root',
transforms: [nameKebabCase(), colorToHex()],
}),
json({
name: 'json',
file: 'tokens-{theme}.json',
preset: 'standalone',
structure: 'flat',
}),
],
})const dispersa = new Dispersa(options?: DispersaOptions)Constructor options:
| Option | Type | Description |
|---|---|---|
resolver |
string | ResolverDocument |
Default resolver (file path or inline object) |
buildPath |
string |
Default output directory |
validation |
{ mode?: 'error' | 'warn' | 'off' } |
Validation behavior ('warn' logs via console.warn) |
Methods:
| Method | Description |
|---|---|
build(config) |
Build tokens. Returns BuildResult (never throws). |
buildOrThrow(config) |
Build tokens. Throws on failure. |
buildPermutation(config, modifierInputs?) |
Build a single permutation. |
resolveTokens(resolver, modifierInputs?) |
Resolve tokens for one permutation without rendering. |
resolveAllPermutations(resolver) |
Resolve tokens for every permutation. |
generateTypes(tokens, fileName, options?) |
Generate a .d.ts file from resolved tokens. |
| Export | Description |
|---|---|
dispersa |
Dispersa class, builder functions (css, json, js), types |
dispersa/transforms |
Built-in transform factories |
dispersa/filters |
Built-in filter factories |
dispersa/builders |
Output builder functions |
dispersa/renderers |
Renderer types, defineRenderer, and outputTree helper |
dispersa/preprocessors |
Preprocessor type |
dispersa/errors |
Error classes (DispersaError, TokenReferenceError, etc.) |
Everything outside these entry points is internal and not a stable API contract.
Resolver -> Preprocessors -> $ref resolution -> Parse/flatten -> Alias resolution -> Filters -> Transforms -> Renderers
- Resolver -- loads sets and applies modifier contexts per the DTCG resolver spec
- Preprocessors -- transform raw token objects before parsing
- $ref resolution -- resolves JSON Pointer references within token documents
- Parse/flatten -- resolves group extensions, validates names, flattens to dot-path keys
- Alias resolution -- resolves
{token.name}references with cycle detection - Filters -- removes tokens (global filters first, then per-output)
- Transforms -- mutates token values and names (global first, then per-output)
- Renderers -- formats tokens into the target output (CSS, JSON, JS, or custom)
See examples/ for complete working projects. Suggested learning path:
| Example | Focus |
|---|---|
basic |
Minimal setup with light/dark themes |
no-filesystem |
In-memory mode with inline tokens |
custom-plugins |
Custom transforms, filters, and renderers |
advanced |
Multi-modifier system with all output formats |
enterprise |
Multi-brand, multi-platform at scale |
MIT