Custom Tokens
Export a token set of your own to Figma, and decide how its collections, text styles and font weights come out.
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.
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.