spektral.yml at your project root says where your design tokens live and what shape they are in. It is entirely optional: a project without one gets the built-in layout, byte for byte.
You write it when your tokens are somewhere other than tokens/, split differently, or built by your own pipeline. Nothing moves: you describe the tree you already have, and Spektral reads and edits it in place.
Spektral offers to write it for you
You rarely have to author this file by hand. Open a project whose tokens are not where Spektral looks, and it scans for the real tree, proposes a config, and shows you what that config does before writing anything.
1. Scan
Looks for *.tokens.json and resolver.json, skipping node_modules, VCS and build directories. Depth- and size-capped, so it cannot spend ten seconds walking a monorepo: and if it does hit a cap it tells you, rather than presenting a partial answer as a complete one.
2. Propose
A directory holding token documents reads as a flat tree; one whose subdirectories hold them reads as one directory per brand. When both readings are possible you get both, ranked, rather than a guess.
3. Dry run
The proposed config is resolved through the real layout engine and every layer read back: layers, brands, and a sample token from each, proving the paths resolve. A wrong proposal visibly reads nothing, before a file lands in your repo.
4. Confirm
You see the YAML verbatim, commented, and only the keys that differ from the default. Nothing is written until you say so, and an existing spektral.yml is never overwritten: you get the two side by side instead.
Two fields are never guessed. cssPrefix is left unset when the evidence is thin or ambiguous, and build is only ever a script your repo already declares: never a command Spektral composed. See both sections below for why guessing either is worse than leaving it out.
Everything the proposal writes, you can edit. It is a plain YAML file with comments. The import step is a one-time bootstrap, not a format you are locked into.
tokens
Every field is optional. Omitting all of them is the built-in layout.
| Field | Type | Default | Description |
|---|---|---|---|
| dir | string | "tokens" | Where the token tree lives, relative to the project root. |
| brandDir | string | "{brand}" | How a brand name maps to a directory under dir. Use "" for a flat tree whose layer files sit directly in dir: brands are then distinguished by filename instead. |
| brandsManifest | string | <dir>/brands.json | Where Spektral records which brand is active. Defaults to .spektral/brands.json for a tree Spektral does not own. |
| defaultBrand | string | "default" | The brand that owns shared layers. |
| resolver | string | auto-detected | A DTCG resolver document, relative to dir. Derives layers and the brand list. Picked up automatically when a file named resolver.json sits in dir and no layers are declared. |
| layers | list | primitive, semantic, component | The layers, in cascade order. See the table below. Declaring these disables resolver derivation entirely. |
| brands | string[] | derived | A fixed brand list. Normally derived: from the manifest, or a resolver's modifier. Setting it locks brand creation and deletion. |
| modes | object | none | Runtime modes (light/dark, high contrast) as sparse overlays on a layer. See modes below. |
| cssPrefix | string | none | The prefix your token build puts on every CSS custom property, without dashes. ds yields --ds-color-background-brand. Written with dashes is accepted and normalised. |
| builtCss | string[] | platform defaults | The stylesheets your token build emits, relative to the project root. These are what CSS validation checks a var(--x) against. |
| build | object | Style Dictionary | How this project turns token JSON into stylesheets. Declared, Spektral runs this instead of its own build. |
tokens.layers[]
| Field | Type | Description |
|---|---|---|
| id | string | The layer's name. Used by the token manager tabs, the agent's layer argument, and cloud sync keys. |
| file | string | Filename inside the brand directory. {brand} and {layer} are substituted. Default "{layer}.tokens.json". |
| files | map | Exact filename per brand, for a flat tree where brands differ by filename rather than directory. |
| label | string | Display name for the token manager tab. Defaults to a title-cased id. |
| shared | boolean | One document owned by defaultBrand and read by every brand. A primitive palette is typically this. |
| shareKeys | boolean | Adding or removing a token here propagates the key (not the value) to every other brand. Defaults to true for a layer called component, since a component reading --button-bg needs that token to exist under every brand. |
Describing a token tree you didn't scaffold
Four shapes cover most real design systems:
| On disk | Config |
|---|---|
| tokens/default/semantic.tokens.json | no file needed |
| packages/ui/src/tokens/ | dir: packages/ui/src/tokens |
| design/tokens/semantic.tokens.json (flat) | dir: design/tokens brandDir: "" |
| packages/ui/src/tokens/resolver.json | dir: packages/ui/src/tokens |
A DTCG resolver in the token directory derives the whole layout on its own: you only have to say where the directory is. Its sets become shared layers, its first modifier becomes the brand axis (contexts become brands, its default the active one).
Two variant axes. Brands and modes. A resolver's second modifier becomes the mode axis when it has two or more contexts (see modes). Modifiers after that have nowhere to map, so their default context is loaded as shared layers and the fact is reported in the token manager: silently dropping half a token tree would read as "the tokens are empty".
Existing projects are unaffected. Every default reproduces the previous behaviour exactly. A project with no spektral.yml changes in no way.
modes
A brand is chosen when you build. A mode is chosen by the browser at runtime: light or dark, high contrast. The default mode is your existing layer files. Every other mode is a sparse overlay beside them, semantic.dark.tokens.json, that redefines only the tokens whose value changes. The build appends each mode to the same stylesheet under its selector. For a walkthrough, see Add dark mode.
tokens: modes: contexts: [light, dark] default: light layers: [semantic]
| Field | Default | Description |
|---|---|---|
| contexts | required | Mode names. At least two, or the block is ignored with a note in the token manager. |
| default | the first context | The mode your base layer files represent. It has no overlay. |
| layers | [semantic] | Layers that may carry overlays. A shared layer (the primitive palette) is refused: dark mode means pointing semantics at other swatches, not a second palette. Component tokens follow their references and rarely need one. |
| file | "{layer}.{mode}.tokens.json" | Overlay filename inside the brand directory. {brand} is substituted too. |
| css.selector | "[data-theme='{mode}']" | Scopes a non-default mode's custom properties. A class selector such as ".theme-{mode}" works too. |
| css.mediaQuery | true | Also emit light/dark under prefers-color-scheme, guarded so an explicit selector still wins. Skipped for a selector that cannot be inverted into that guard. |
An overlay can redefine, never add. A token in the overlay that the base layer does not have is dropped and listed in the build log, so a component can never read a variable that exists in one mode only.
A declared block wins over a resolver. Without one, a resolver's second modifier supplies the modes. A hand-written modes: replaces that, just as hand-written layers: replace derived ones.
A custom build is told the modes. A declared build receives SPEKTRAL_MODES (comma-separated) and SPEKTRAL_DEFAULT_MODE in its environment.
cssPrefix and builtCss
Where your token JSON lives is one question. What your build names the CSS custom properties, and where it writes them, is another: and these two are worth getting right even if you set nothing else.
Why the prefix matters. cssPrefix decides the names Spektral tells the AI agent to use, the names the Figma mapper suggests, and the names CSS validation compares against. Get it wrong and the agent writes var(--color-background-brand) into a library that only ever defined --ds-color-background-brand: CSS that parses, renders unstyled, and fails no check. This is the single most expensive field on the page to leave wrong.
It is Style Dictionary's prefix, written without dashes. If your build config sets prefix: 'ds', set cssPrefix: ds here.
The import step infers it by cross-referencing rather than guessing: it turns your real token paths into the property names Style Dictionary would emit for them, then checks which custom properties your stylesheets actually define. A stylesheet only counts if most of its properties are token names: hand-written app CSS defines --color-primary by coincidence, a generated one defines little else. If the evidence is thin or two prefixes fit equally well, it tells you and leaves the field unset, because an unset prefix is a smaller problem than a wrong one.
builtCss is the other half: the stylesheets your build emits. It defaults to where Spektral's own build writes, so a repo building its tokens elsewhere needs to say so: otherwise the read fails, the set of known properties comes back empty, and the existence check quietly passes everything rather than reporting that it could not run.
build
Spektral's own token build is Style Dictionary rooted inside the platform it scaffolds. A repo that owns its pipeline has no such file, and three features hit that as a spawn failure: Storybook start, Save & Rebuild, and brand switching.
tokens: build: command: npm args: ["run", "build:tokens"] # {brand} is substituted cwd: packages/ui # relative to the project root timeout: 120000 # ms, capped at 600000
It runs once per project, not once per platform. The build belongs to the repo, not to a platform target.
The brand travels both ways. {brand} is substituted into args, and SPEKTRAL_BRAND is set in the environment: so a build that emits every theme in one pass can ignore the argument entirely and still know which brand is active.
The import step selects this, it never writes one. A command invented from the file tree would be a build that fails the first time you use it: but a package.json script is not a guess, it is a command your repo already declares about itself. Spektral reads the package.json files from your token directory upwards, takes the scripts whose name says tokens, and confirms one against a file it references: build:tokens running node sd.config.mjs counts only if that file is there. Exactly one survivor is proposed. If it is ambiguous, an AI model picks from the scripts found: it cannot compose a new one, and its answer is checked back against your package.json before it is used. Anything less than certain is left commented out.
Your token directory stays yours
When dir points somewhere other than the default, Spektral treats the tree as your repo's rather than its own. Opening the project reads it and leaves no diff in it: only edits you actually make are written back.
| Bookkeeping | Goes to |
|---|---|
| Which brand is active | .spektral/brands.json |
| Staged build input | .spektral/build/<brand> |
| Anything else Spektral would seed | not written at all |
Worked example: a DTCG resolver repo
A repo keeping its tokens at packages/ui/src/tokens, split per the DTCG resolver spec: a foundation set that is always loaded, and a modifier with light and dark contexts.
packages/ui/src/tokens/ resolver.json foundation.tokens.json semantic.light.tokens.json semantic.dark.tokens.json packages/ui/src/styles/ tokens.css # built output, prefixed --ds- tokens.dark.css
Its entire config: which is what the import step proposes for it:
tokens: dir: packages/ui/src/tokens # The build sets Style Dictionary's prefix: 'ds', so every custom property # is --ds-color-background-brand, not --color-background-brand. cssPrefix: ds # Where that build writes. Not where Spektral's own build would put them. builtCss: - packages/ui/src/styles/tokens.css - packages/ui/src/styles/tokens.dark.css build: command: npm args: ["run", "build:tokens"] cwd: packages/ui
dir is all the layout needs: the resolver.json beside those files supplies the layers (foundation, shared; plus one per theme) and the light/dark brands. The other three describe the build.
Because it is the resolver's only modifier, light and dark are read as brands here, built one at a time into separate stylesheets. That matches this repo's build. To switch between them at runtime from one stylesheet instead, see Add dark mode.
Full Example
Every field at once, for reference. Real configs are much shorter: write only what differs from the default.
tokens: dir: packages/ui/src/tokens brandDir: "{brand}" brandsManifest: .spektral/brands.json defaultBrand: default layers: - id: primitive file: "{layer}.tokens.json" shared: true - id: semantic - id: component shareKeys: true modes: contexts: [light, dark] default: light layers: [semantic] file: "{layer}.{mode}.tokens.json" css: selector: "[data-theme='{mode}']" mediaQuery: true cssPrefix: ds builtCss: - packages/ui/src/styles/tokens.css build: command: npm args: ["run", "build:tokens"] cwd: packages/ui timeout: 120000
Bringing an existing component library too? That is the sibling config: see the platform.yml reference, which covers reading and editing components where they already are.