Skip to content

Custom Tokens

Export a token set of your own to Figma, and decide how its collections, text styles and font weights come out.

View source View as Markdown

The Figma export reads its collections from figma.collections in your mirror.config.ts, so a token set that shares nothing with ours comes out as its own variables. What it needs from you is that key, declaring each collection with the targets its modes read. Text styles additionally need a typography file authored against your own paths, or figma.typography: false to skip them, and a font weight outside the built-in table needs a face name in figma.fonts. Each of these is walked below, starting with a small set built end to end.

A small set, end to end

The token source is two files. tokens/brand.json holds the base set, with no app. prefix anywhere, and tokens/theme/dark.json restates the four colors and nothing else.

{
  "brand": {
    "color": {
      "bg": {
        "base": { "$value": "oklch(0.97 0.01 90)", "$type": "color" },
        "raised": { "$value": "oklch(1 0 0)", "$type": "color" }
      },
      "fg": {
        "default": { "$value": "oklch(0.25 0.02 90)", "$type": "color" }
      },
      "accent": { "$value": "oklch(0.65 0.17 45)", "$type": "color" }
    },
    "radius": {
      "card": { "$value": "0.75rem", "$type": "dimension" },
      "pill": { "$value": "62rem", "$type": "dimension" }
    },
    "heading": {
      "size": { "$value": "2rem", "$type": "fontSize" }
    }
  }
}

The heading size deliberately declares $type: "fontSize", singular, which is off the export’s vocabulary; the run below shows how such a token is handled. The config declares both targets, maps them into a color and a dimension collection, and turns text styles off, since this source has no typography file.

import { defineMirrorConfig } from '@maas/mirror'

export default defineMirrorConfig({
  source: { type: 'local', path: './tokens' },
  options: { platforms: ['css', 'json'] },
  figma: {
    typography: false,
    collections: [
      {
        name: 'Color',
        types: ['color'],
        modes: [
          { name: 'Base', targets: ['application'] },
          {
            name: 'Dark',
            targets: ['application', 'theme/dark/application'],
          },
        ],
      },
      {
        name: 'Dimension',
        types: ['dimension'],
        modes: [{ name: 'Base', targets: ['application'] }],
      },
    ],
  },
  targets: {
    application: {
      selector: ':root',
      files: [{ src: 'brand' }],
    },
    'theme/dark/application': {
      include: [{ src: 'brand' }],
      selector: '[data-mode="dark"]',
      files: [{ src: 'theme/dark' }],
    },
  },
})

Run pnpm mirror tokens, then pnpm mirror figma, and the export comes out as follows.

.maas/figma/
  color/base.json
  color/dark.json
  dimension/base.json
  styles.json

The two collections come out exactly as declared. The Dark mode reads application first and theme/dark/application after it, so it holds all four colors with the restated dark values winning, and the dimension collection holds the radii in its single Base mode. The heading size reached no collection at all, because neither types list claims fontSize, which the run reports rather than keeping quiet about:

1 token(s) of $type 'fontSize' are claimed by no collection and will not reach Figma.

Inside the mode files, the tree arrives under its own names. This entry in color/base.json imports as the variable brand/color/accent, resolved to sRGB with the token path and the authored value in its description.

"accent": {
  "$type": "color",
  "$value": {
    "colorSpace": "srgb",
    "components": [0.878228, 0.399019, 0.135458],
    "alpha": 1,
    "hex": "#e06623"
  },
  "$description": "brand.color.accent · oklch(0.65 0.17 45)"
}

Declaring collections

figma.collections states which collections and modes the export writes, and the key is required. Running mirror figma without it fails, and the error prints a suggested roster read off your targets, ready to paste: every collection opens with a Base mode reading application, and each theme/<name>/application or breakpoint/<name>/application target that declares tokens of a collection’s types is offered as a further mode. A first config can start by pasting that suggestion and trimming it, and what ends up in the file is the whole roster; the export adds nothing of its own.

A roster that crosses a theme axis with a density axis looks like this.

figma: {
  collections: [
    {
      name: 'Color',
      types: ['color'],
      modes: [
        { name: 'Base', targets: ['application'] },
        { name: 'Dark', targets: ['application', 'theme/dark/application'] },
      ],
    },
    {
      name: 'Dimension',
      types: ['dimension'],
      modes: [
        { name: 'Base', targets: ['application'] },
        {
          name: 'Compact',
          targets: ['application', 'density/compact/application'],
        },
      ],
    },
  ],
}

name is the collection as Figma shows it, and the directory its mode files are written into. types names the source $type values that belong to the collection, and tokens of any other type in those targets are ignored. Each mode lists keys of targets, read left to right with the last target that declares a token winning it, so an overlay only needs to restate what it changes. A mode may reference any target in the config, whatever axis its key sits under, which is what the Compact mode above does with density/compact/application.

The run checks the roster against the targets in both directions. A mode naming a key that targets does not have fails the run, and a target under theme/ or breakpoint/ ending in /application, nested keys included, that no mode references is reported on every run:

Target 'theme/contrast/application' is referenced by no figma.collections mode and will not reach Figma.

Targets under other axes are not checked, so a density target referenced by no mode passes without a warning.

Names and types

The variable names are the token paths, slash-joined. The two stripping rules are written against our tree: a color path starting app.color. loses that prefix and its component segment, and any other path loses a leading app. alone. A path that starts with neither passes through whole, with nothing stripped and no segment folded, so the difference is purely cosmetic.

TokenVariable
app.color.component.primary.bg.solid.defaultprimary/bg/solid/default
brand.color.bg.basebrand/color/bg/base

Which tokens become variables at all is decided by the types your collections claim. Our compiler writes five typography type names, fontFamily, fontWeight, fontSizes, lineHeight and letterSpacing, and our stock typography collection claims exactly those. Note the plural: sizes are fontSizes, and a source declaring fontSize instead sees those tokens in the warning the small set above tripped rather than in Figma. A $type no collection claims is never dropped silently; it is counted and reported once per run.

Text styles

The composite text styles do not come out of the compiled targets. They resolve from a typography file in your token source, figma/type.json unless figma.typography names another path, which mirror tokens copies verbatim to <dist>/tokens/figma/type.json. Each token in it is a composite whose five members are references into your own compiled base, so authoring your own is a matter of pointing those references at your own paths.

{
  "heading": {
    "lg": {
      "$type": "typography",
      "$value": {
        "fontFamily": "{brand.heading.family}",
        "fontWeight": "{brand.heading.weight}",
        "fontSize": "{brand.heading.size.lg}",
        "lineHeight": "{brand.heading.leading}",
        "letterSpacing": "{brand.heading.tracking}"
      }
    }
  }
}

That token becomes the text style heading/lg, named by its path in the file. All five members are required, each one a reference in braces, and each reference resolves one hop into the compiled base of the typography collection, the tree its first declared mode reads; a member that is missing or does not resolve fails the run naming the token and the member. Family, weight and size have to resolve to tokens the typography collection exports as variables, because those three fields are bound rather than copied, which is also why the referenced size has to be a fontSizes token even though the member key is fontSize.

If your source has no such file, set figma.typography: false and the run skips text styles and nothing else: the variables and the effect styles still come out. Left unset with no file on disk, mirror figma stops and names the path it looked for.

Font weights

A text style needs a concrete face name before Figma can load the font, so the numeric weight resolves to one through a built-in table covering the hundred steps 100 through 900, Thin up to Heavy, plus 450 as Book. A weight outside that table fails the run and names the exact key that fixes it.

Text style heading.lg: no Figma style name for weight 350 of Brand Sans.
Add `figma.fonts['Brand Sans'].weights[350]` to name it.
figma: {
  fonts: {
    'Brand Sans': { family: 'Brand Sans Trial', weights: { 350: 'Light' } },
  },
}

weights names the face a numeric weight loads, and it also overrides the table where a family names its faces differently. family swaps the family name everywhere it reaches Figma, in the fontFamily variables and the text styles alike, for a font installed under a different name than the tokens use.

Further reading

  • Variables: the import mechanics, and what a re-run changes.
  • Styles: the plugin that writes the text and effect styles.
  • Configuration: mirror.config.ts, targets and sources end to end.
  • CLI: the command, and the arguments the build commands share.