Skip to content

Design Tokens

Convert design tokens into CSS variables through the engine's variables system.

The design tokens plugin reads token sources — inline objects, W3C Design Tokens (DTCG) JSON files, or markdown design documents — flattens them into CSS variables, and merges them into the engine's variables config. Because tokens flow through the core variables system, they inherit unused-pruning, IDE autocomplete, and selector scoping. Token source file paths are registered as engine config dependencies — even when the file is missing — so build-tool integrations reload when a token file changes or is created later.

sh
pnpm add -D @pikacss/plugin-design-tokens
sh
npm install -D @pikacss/plugin-design-tokens
sh
yarn add -D @pikacss/plugin-design-tokens

Choosing an Entry

The plugin ships two entry points. Which one you import decides whether file-backed token sources can be read:

  • @pikacss/plugin-design-tokens/node — the Node.js adapter. It injects node:fs and process.cwd(), so file sources (.json, .md, and custom loaders) are read from disk. Use this in any bundler or Node build (Vite, webpack, Nuxt, …).
  • @pikacss/plugin-design-tokens — the platform-neutral entry. It accepts inline token objects only. A file source passed here is warned about and skipped, because no filesystem capability is installed. Use it in non-Node hosts, or pass a custom runtime capability (designTokens({ readFile, cwd })).

Most projects import from /node:

ts
import { defineEngineConfig } from '@pikacss/core'
import { designTokens } from '@pikacss/plugin-design-tokens/node'

export default defineEngineConfig({
	plugins: [designTokens()],
	designTokens: {
		sources: ['./design.md'],
	},
})

Configure the plugin through the top-level designTokens engine config key.

Usage: reference the generated variables from regular pika() calls. Tokens are pruned when unused by default, so only referenced variables are emitted:

ts
const cardClassName = pika({
	backgroundColor: 'var(--color-surface)',
	color: 'var(--color-primary)',
	boxShadow: 'var(--shadow-md)',
})
css
@layer preflights {
  :root {
    --color-primary: #3b82f6;
    --color-surface: #f8fafc;
    --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
  }
  .dark {
    --color-surface: #0f172a;
  }
}

@layer utilities {
  .pk-a {
    background-color: var(--color-surface);
  }
  .pk-b {
    color: var(--color-primary);
  }
  .pk-c {
    box-shadow: var(--shadow-md);
  }
}

Token Sources

sources accepts a single source or an array of sources. Each source is either an inline token group object or a file path. Relative paths resolve against the effective token root: an absolute root is used as-is, a relative root resolves from the engine host's project root (e.g. the Vite root or Nuxt rootDir your bundler integration supplies), and when root is omitted the project root itself is used. Standalone createEngine() use without a host context falls back to process.cwd() under the /node entry ('.' when no runtime capability is provided). Later sources override earlier ones when variable names collide. Unreadable or invalid sources are skipped with a warning instead of failing engine creation; a file source read through the neutral entry (no readFile capability) is likewise warned about and skipped.

JSON Token Files

Any file path that does not end in .md is parsed as a W3C Design Tokens JSON file. A node with a $value property is a token; any other object is a nested group. $-prefixed group metadata keys (such as $description) are skipped:

json
{
	"color": {
		"primary": { "$value": "#3b82f6", "$type": "color" },
		"accent": { "$value": "{color.primary}" }
	}
}

Markdown Design Documents

File paths ending in .md are parsed as design documents. Only fenced code blocks whose info string starts with tokens are read; all other markdown content is ignored, so tokens can live inside your design documentation. Block content must be valid JSON in the W3C Design Tokens format:

md
# Buttons

Primary buttons use the brand color.

```tokens
{ "color": { "primary": { "$value": "#3b82f6" } } }
```

The dark theme swaps in a lighter shade.

```tokens theme=dark selector=".dark"
{ "color": { "primary": { "$value": "#60a5fa" } } }
```

The info string may carry two attributes:

  • theme=<name> — assigns the block's tokens to a theme instead of the base :root scope.
  • selector=<css-selector> — overrides the theme selector for that block. Values may be quoted with " or '. A fence selector takes precedence over the selector configured under themes.

Per-Source Prefix and Layer

A source may be a bare source or a DesignTokensSourceEntry object that carries its own prefix and layer. An object is treated as a source entry when it has an own source property; otherwise it is read as an inline token group. Use the entry form to namespace one vendor file, or to tag a source's architectural layer:

ts
designTokens: {
	sources: [
		'./app.tokens.json',
		{ source: './vendor.tokens.json', prefix: 'syno', layer: 'semantic' },
	],
}
  • prefix overrides the top-level prefix for that source's emitted variable names and its own {a.b.c} alias resolution. A cross-source $ref into this source uses this prefix for the emitted name.
  • layer is 'primitive' (raw values — a palette, a spacing scale) or 'semantic' (values mapped to intent — surface, text). The layer is recorded on the generated variables' internal registry; it does not affect the emitted CSS, but strict mode can enforce that authored styles reference only semantic tokens.

INFO

To use an inline token group whose top level literally contains a token or group named source, wrap it in an entry: { source: <group> }.

DTCG Ingestion

Every loaded source is canonicalized by a built-in DTCG normalizer before it is flattened. This adds three W3C Design Tokens features on top of plain $value parsing:

  • $ref JSON pointers — a node that carries $ref (and no $value) is a token expressed purely as a reference, written as a JSON pointer like "file#/color/primary" or, for a same-file reference, "#/color/primary". It is rewritten into an alias to the target's emitted variable, so cross-file references resolve to the target source's prefix. Malformed pointers (bad syntax, non-string $ref, unknown source, missing target, circular chain) are warned about and skipped — never thrown or looped.
  • Group-level $type — a $type set on a group flows down onto every descendant token, unless a token sets its own $type (token wins). This drives autocomplete and strict-mode governance without repeating $type on every leaf.
  • Group-level $deprecated — deprecated tokens still emit their CSS variables, but their variable names are recorded so tooling can warn on usage. A group-level $deprecated applies to every descendant unless a token overrides it. Deprecated tokens in use are counted in the usage report.
ts
const badgeClassName = pika({
	// `brand` is a $ref alias to color.primary; using it keeps the target alive.
	color: 'var(--color-brand)',
	// `legacy` is $deprecated but still emits its variable.
	backgroundColor: 'var(--color-legacy)',
})
css
@layer preflights {
  :root {
    --color-primary: #3b82f6;
    --color-brand: var(--color-primary);
    --color-legacy: #dc2626;
  }
}

@layer utilities {
  .pk-a {
    color: var(--color-brand);
  }
  .pk-b {
    background-color: var(--color-legacy);
  }
}

Arbitrary $extensions metadata is carried through onto the normalized token. The com.pikacss.design-tokens namespace is reserved for PikaCSS's own metadata — see External Aliases.

External Aliases

An external alias is a token whose value is owned by another runtime — for example, a design system that already ships its own CSS custom properties (--guideline-*) at runtime. Mark such a token with an external-alias marker under $extensions["com.pikacss.design-tokens"]:

json
{
	"surface": {
		"z0": {
			"$type": "color",
			"$value": "var(--guideline-semantic-surface-z0)",
			"$extensions": {
				"com.pikacss.design-tokens": {
					"external": true,
					"var": "--guideline-semantic-surface-z0"
				}
			}
		}
	}
}

The marker is authoritative: when both a marker and a $value exist, the marker wins, and var must be a ---prefixed string (an invalid marker is warned about and skipped). External aliases are emitted only under :root and are never themed — the external runtime owns theming, so an external alias placed in a theme scope is warned about and skipped. This lets your tokens reference a design system's live variables while still flowing through autocomplete, pruning, and strict mode:

ts
const panelClassName = pika({
	backgroundColor: 'var(--syno-surface-z0)',
	color: 'var(--syno-text-primary)',
})
css
@layer preflights {
  :root {
    --syno-surface-z0: var(--guideline-semantic-surface-z0);
    --syno-text-primary: var(--guideline-semantic-text-primary);
  }
}

@layer utilities {
  .pk-a {
    background-color: var(--syno-surface-z0);
  }
  .pk-b {
    color: var(--syno-text-primary);
  }
}

Themes

Base tokens are emitted under :root. Theme tokens are emitted under the theme's selector, which defaults to .<themeName> and can be overridden via themes.<name>.selector (or per-block via the fence selector attribute). Theme sources use the same formats as base sources:

ts
import { defineEngineConfig } from '@pikacss/core'
import { designTokens } from '@pikacss/plugin-design-tokens/node'

export default defineEngineConfig({
	plugins: [designTokens()],
	designTokens: {
		sources: ['./design.tokens.json'],
		themes: {
			dark: {
				selector: '[data-theme="dark"]',
				sources: ['./design.dark.tokens.json'],
			},
		},
	},
})

Multi-Theme From a Single File

When one shared source holds several theme partitions at its top level (e.g. light-mode, dark-mode), each theme selects its own partition with from. The partition key is stripped from token paths, so the emitted variable names stay theme-agnostic (--surface-z0, not --light-mode-surface-z0). Passing an array of keys merges the selected subtrees (later keys win on collision).

Set media to additionally emit a theme's variables inside an @media block wrapping :root, on top of its selector block — so a theme activates both via an explicit class/attribute and automatically via a user preference:

ts
const surfaceClassName = pika({
	backgroundColor: 'var(--surface-z0)',
	color: 'var(--text-primary)',
})
css
@layer preflights {
  :root,:root.light {
    --surface-z0: #f7f7f7;
    --text-primary: #292929;
  }
  :root.dark {
    --surface-z0: #1c1c1c;
    --text-primary: #f7f7f7;
  }
  @media (prefers-color-scheme: light) {
    :root {
      --surface-z0: #f7f7f7;
      --text-primary: #292929;
    }
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --surface-z0: #1c1c1c;
      --text-primary: #f7f7f7;
    }
  }
}

@layer utilities {
  .pk-a {
    background-color: var(--surface-z0);
  }
  .pk-b {
    color: var(--text-primary);
  }
}

Token Names and Aliases

Each token path segment is kebab-cased (fontSizefont-size), then segments are joined with - to form the variable name, e.g. color.primary--color-primary. When prefix is set, it is prepended as the first segment: --app-color-primary.

String values may reference other tokens with alias syntax {path.to.token}, which resolves to var(--path-to-token) using the same normalization and prefix — aliases always point at the emitted variable name. Aliases also work embedded in longer values, e.g. '1px solid {color.border}'1px solid var(--color-border).

Composite Values

Object and array $values are serialized based on $type:

$typeSerialization
shadow[inset] <offsetX> <offsetY> <blur> <spread> <color> — missing offsets default to 0
border<width> <style> <color>
transition<duration> <timingFunction> <delay>
(arrays, any type)Items serialized individually and joined with , (layered shadows, fontFamily stacks)

Object values with any other $type (e.g. typography) have no single-value serializer. They are expanded into one sub-variable per field: typography.heading with { fontSize: '2rem' } becomes --typography-heading-font-size.

Autocomplete

A token whose $type is mapped to CSS properties emits asValueOf autocomplete for exactly those properties, so the variable is suggested as a var() value where it belongs. The built-in map covers common DTCG types:

$typeSuggested for
colorcolor, background-color, border-color, outline-color, fill, stroke
dimensionwidth, height, min/max-width, min/max-height, margin, padding, gap, inset, font-size, border-radius
durationtransition-duration, animation-duration
fontFamilyfont-family
fontWeightfont-weight
numberz-index, opacity, line-height, flex-grow, flex-shrink, order
shadowbox-shadow
cubicBeziertransition-timing-function, animation-timing-function

typeAutocomplete merges over this map per $type. Each entry replaces the default list for that $type; false suppresses value-of suggestions entirely. Tokens without a $type, or with a $type absent from the merged map, fall back to the core variables default (suggested everywhere):

ts
designTokens: {
	// Suggest `spacing` tokens only for gap/padding/margin; never suggest `z` tokens as values.
	typeAutocomplete: {
		spacing: ['gap', 'padding', 'margin'],
		z: false,
	},
}

Strict Mode

Strict mode governs which literal values are allowed on design-token-governed CSS properties and surfaces violations as diagnostics. A property is governed when it appears in the merged typeAutocomplete map for a $type that has at least one registered token; values on governed properties are validated against that $type. Strict mode is opt-in and defaults to off (a near-zero-cost early return), so a valid, token-referencing style passes untouched:

ts
// Valid usage under strict mode: a governed property references a token of the
// matching $type, so no diagnostic is produced and the CSS emits normally.
const buttonClassName = pika({
	color: 'var(--color-primary)',
	backgroundColor: 'var(--color-surface)',
})
css
@layer preflights {
  :root {
    --color-primary: #3b82f6;
    --color-surface: #f8fafc;
  }
}

@layer utilities {
  .pk-a {
    color: var(--color-primary);
  }
  .pk-b {
    background-color: var(--color-surface);
  }
}
ts
designTokens: {
	strict: {
		level: 'error',
		overrides: { 'background-color': 'warn', dimension: 'off' },
		allowedValues: ['0', /^var\(--legacy-/],
		semanticOnly: true,
		types: true,
	},
}
OptionBehavior
levelBaseline severity for every governed property: 'off' (default), 'warn', or 'error'.
overridesPer-key severity, keyed by a CSS property name (e.g. 'background-color') or a $type (e.g. 'color'). A property-key override beats a $type-key override, which beats level.
allowedValuesExtra literals accepted on any governed property, on top of the built-in per-$type allowlist (color: transparent, currentcolor; dimension: 0, auto) and the CSS-wide keywords. A string is matched exactly against the trimmed value; a RegExp is tested against it.
semanticOnlyWhen true (and level is not 'off'), referencing a primitive-layer token in authored styles is a violation — only semantic-layer tokens are allowed — and primitive tokens are hidden from autocomplete.
typesWhen true, narrows the generated pika.gen.ts value types (see below).

A strict violation becomes a Diagnostic{ level, code, message, plugin: 'design-tokens' } — reported through the engine's onDiagnostic handler while styles are transformed. The engine never throws it. The bundler integration installs a handler that logs every diagnostic live (a 'warning' surfaces immediately, including in dev), collects the 'error'-level ones, and fails the build by throwing a single aggregated Error at buildEnd — so an error-severity violation stops a production build. See Diagnostics and reports below.

Compile-Time Type Narrowing

strict.types is independent of level: it narrows the generated pika.gen.ts so invalid literals red-squiggle in the IDE before any build runs. For each governed property, the accepted TypeScript value type becomes an exclusive union of: a var(--token) reference (with an optional var(--token, fallback) form) for each token of the governing $type, the CSS-wide keywords, the built-in allowlist and any string allowedValues, and template-literal escape hatches for the functional forms calc(), color-mix(), min(), max(), clamp(), and light-dark().

WARNING

If any RegExp allowedValues entry is present, type narrowing is disabled entirely (all properties stay permissive), because an arbitrary RegExp cannot be faithfully represented as a literal union without risking rejection of a value the runtime accepts. Runtime diagnostics still apply.

Custom Loaders and Normalizers

loaders and normalizers extend ingestion without changing the built-in behavior:

  • loaders turn a file path into a raw value. For each string source, the first loader whose match(id) returns true wins; if none match, the built-in .md/JSON handling applies. Inline object sources bypass loaders. Register a source path with ctx.addDependency(id) (before reading it) so integrations reload on change. A loader's ctx.readFile is the same host capability the plugin was constructed with, so loaders only read files under the /node entry (or a custom runtime).
  • normalizers run as an ordered chain over each loaded raw value — after the built-in DTCG normalizer, before the flatten stage — each receiving the previous one's output and returning a DesignTokenGroup. With no normalizers configured, raw values pass through unchanged.
ts
import { parse as parseYaml } from 'yaml'

designTokens: {
	loaders: [
		{
			name: 'yaml',
			match: id => id.endsWith('.yaml') || id.endsWith('.yml'),
			load: async (id, ctx) => {
				ctx.addDependency(id)
				return parseYaml(await ctx.readFile(id))
			},
		},
	],
}

Adapting a vendor format with a normalizer

A loader only decides how to read a source; when a design tool exports a shape that is not W3C Design Tokens — a flat list instead of a nested tree, say — a normalizer converts it to a canonical DesignTokenGroup. This is how you adapt a vendor dialect without pre-transforming the file by hand. The built-in DTCG normalizer runs first but passes shapes it does not recognize through untouched, so your normalizer still receives the raw vendor data; conversion happens before the flatten stage, so no "invalid token node" warnings are emitted.

ts
import type { DesignTokenGroup } from '@pikacss/plugin-design-tokens'

// The vendor exports a flat list rather than a nested DTCG tree:
//   { "tokens": [{ "path": "color.brand", "value": "#3b82f6", "type": "color" }, …] }
interface VendorToken { path: string, value: string, type: string }

designTokens: {
	normalizers: [
		{
			name: 'vendor-flat-list',
			normalize: (raw) => {
				const group: DesignTokenGroup = {}
				const { tokens = [] } = raw as { tokens?: VendorToken[] }
				for (const { path, value, type } of tokens) {
					const segments = path.split('.')
					// Walk/create the nested group for every path segment but the last.
					let node = group as Record<string, any>
					for (const segment of segments.slice(0, -1))
						node = (node[segment] ??= {})
					node[segments[segments.length - 1]!] = { $value: value, $type: type }
				}
				return group
			},
		},
	],
}

Package a loader/normalizer pair like this as its own module to reuse a vendor adapter across projects; the seam is designed so adapters can live outside the core plugin.

Usage Report

The engine exposes a design-token surface at engine.designTokens when the plugin is registered. engine.designTokens.report() returns a DesignTokensReport — total registered tokens, used/unused variable names, deprecated tokens in use, and cumulative strict-violation counts computed from the current atomic-style store. Strict-mode diagnostics are delivered live through the engine's onDiagnostic handler, so there is no queue to drain.

Diagnostics and reports

The bundler plugin surfaces this work through its diagnostic wiring and one unplugin option:

  • Diagnostics — strict-mode violations reach the bundler through the engine's diagnostic channel. The unplugin logs each one live and throws an aggregated build error at buildEnd for any 'error'-level diagnostic, so an error-severity violation fails the build. There is no per-plugin onDiagnostic option to set: the behavior is built in.
  • report — set to true to log a usage summary once per production build; pass { output } to also write the full report as JSON to that path. The report is emitted only in build mode, so a dev server is never spammed per HMR update.
ts
// vite.config.ts
import PikaCSS from '@pikacss/unplugin-pikacss/vite'

export default {
	plugins: [
		PikaCSS({
			report: { output: './design-tokens.report.json' },
		}),
	],
}

See Unplugin for the full option reference.

Config

PropertyDescription
sourcesBase token sources emitted under :root — inline token group objects, file paths, or { source, prefix?, layer? } entries. Later sources override earlier ones when names collide.
themesTheme overrides keyed by theme name. Each theme takes a selector (default .<themeName>), an optional media query, an optional from partition selector, and its own sources.
loadersCustom source loaders, tried before the built-in .md/JSON handling. First matching loader wins.
normalizersOrdered normalizer chain applied after the built-in DTCG normalizer and before flattening.
typeAutocompletePer-$type autocomplete override map, merged over the built-in map. A false value suppresses value-of suggestions for that $type.
strictStrict-mode governance: level, per-key overrides, allowedValues, semanticOnly, and compile-time types. Default: off.
prefixPrefix prepended to every generated CSS variable name (without leading --). Default: ''.
rootBase directory used to resolve relative source file paths. Absolute paths are authoritative; a relative value resolves from the engine host's project root. Default: the host project root, falling back to process.cwd() under the /node entry ('.' when no runtime capability is provided).
pruneUnusedPruning override applied to every generated variable. When unset, the variables config default applies (unused tokens are pruned).

See API Reference — Plugin Design Tokens for full type signatures and defaults.

Next

  • Fonts — web font loading and management.
  • Variables — the core variables system that design tokens feed into.
  • Unplugin — the report option and the diagnostic channel that surfaces strict-mode violations.