Skip to content

Extending

Add your own utility families in mirror.config.ts, and narrow or drop ours by role rather than by class name.

View source View as Markdown

The generator’s config never names a group, a rung or a state. It names roles, token paths and the shape of the CSS, and everything else falls out of the token tree, so adding a token gets you a class and adding a subtree gets you a family.

You append emitters under utilities.emitters, and narrow or drop Mirror’s own under utilities.overrides.

An emitter

KeyType
role
string
values
Record<string, TokenPath | TokenPath[]>
omit
string[]
include / exclude
RungPattern[]
families
Family[]

A Family is made up of a prefix, a properties map of property to value template, an optional nested map of selectors like &::before, its own include and exclude, and static to emit one block per rung instead of one functional family. A RungPattern is a glob where * matches any run of characters, such as 'surface*' or '*-hover'.

Value templates take three forms, and everything else is copied through as CSS, so blur({}) and linear-gradient(180deg, {stop0} 0%, {stop100} 100%) both work.

FormExpands to
{}--value(--<ns>-<role>-*)
{leading}--value(--<ns>-<role>-leading-*)
@color.focus.borderThe token’s literal value, hoisted as a constant.

A worked example

Say you want a material-* family with a gradient fill, a backdrop blur and a ::before border mask. It starts with the tokens, where material is just another subtree and is discovered like any other.

color.material.alpha.bg
color.material.alpha.bg.gradient.stop.0
color.material.alpha.bg.gradient.stop.100
color.material.alpha.border
dimension.material.alpha.blur
boxShadow.material.alpha

Then one emitter.

import { extendMirrorConfig } from '@maas/mirror'

export default extendMirrorConfig({
  source: { type: 'git', repo: 'magicasaservice/mirror-tokens' },
  utilities: {
    emitters: [
      {
        role: 'material',
        values: {
          blur: 'dimension.material.*.blur',
          shadow: 'boxShadow.material.*',
          bg: 'color.material.*.bg',
          stop0: 'color.material.*.bg.gradient.stop.0',
          stop100: 'color.material.*.bg.gradient.stop.100',
          border: 'color.material.*.border',
        },
        families: [
          {
            prefix: 'material',
            properties: {
              position: 'relative',
              'border-radius': 'inherit',
              'backdrop-filter': 'blur({blur})',
              'box-shadow': '{shadow}',
              background:
                'linear-gradient(180deg, {stop0} 0%, {stop100} 100%), {bg}',
            },
            nested: {
              '&::before': {
                content: "''",
                position: 'absolute',
                inset: '0',
                padding: '1px',
                'border-radius': 'inherit',
                background: '{border}',
                mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
                'mask-composite': 'exclude',
                'pointer-events': 'none',
              },
            },
          },
        ],
      },
    ],
  },
})

That emits one @utility material-* block plus six theme sub-namespaces, and material-alpha through material-zeta all fall out of color.material.*. Adding material.eta to the tokens adds the class with no config change.

Narrowing what Mirror emits

Appending is not always enough. A functional bg-* and a static bg-surface-low compile at equal specificity, and Tailwind sorts the functional one last whatever the import order, so an app that already paints its own bg-* cannot import the preset without the generated family shadowing its layer.

That is why every emitter is addressable by role. utilities.overrides is keyed by role name and applies to Mirror’s own emitters and to appended ones alike.

OverrideEffect
falseDrops the role. No families, no theme keys, no class names.
valuesReplaces the paths a sub-namespace reads. false drops the sub-namespace, and with it every family referencing it.
include / excludeGlobs over rung names, applied at discovery.
familiesKeyed by the family’s prefix. false drops that family; a string renames its prefix.

If your app brings its own colour layer, dropping the role outright is the answer.

export default extendMirrorConfig({
  source: { type: 'git', repo: 'magicasaservice/mirror-tokens' },
  utilities: {
    overrides: {
      bg: false,
      fg: false,
      border: false,
    },
  },
})

Typography, radius, shadow and the focus ring still generate, while --mirror-bg-*, --mirror-fg-* and --mirror-border-* are never declared, so the app’s own bg-* blocks are the only ones in the sheet.

If you want Mirror’s colours and your own, rename instead of dropping.

overrides: {
  bg: { families: { bg: 'mirror-bg' } },
}

If you only want part of a role, narrow it.

overrides: {
  bg: { include: ['surface*'] },
  border: { exclude: ['*-hover', '*-active'], families: { divide: false } },
}

The narrowing is enforced by the theme namespace rather than by a list of class names. --value(--mirror-bg-*) resolves against the keys in the @theme block, so a rung filtered out at the role level has no key at all and bg-primary-solid stops compiling rather than merely going undocumented. A filter that leaves nothing behind disables the role with a warning instead of emitting an empty family.

Pointing a role at different paths

values on an override replaces the paths a sub-namespace reads, which is what a token tree shaped differently from ours needs. Say yours writes the intents one segment shallower, without a component group in between.

overrides: {
  bg: { values: { value: 'color.*.bg.**' } },
  fg: { values: { value: 'color.*.fg.**' } },
}

The class names are unaffected, because a rung is built from the segments the wildcards captured rather than from the path they sit at, so color.brand.bg.solid.hover still gets you bg-brand-solid-hover.

What an override cannot reach is a family’s properties, and with it the @path literals inside them. A role built on literals rather than on discovery, focus being ours, has to be dropped and written again.

utilities: {
  overrides: { focus: false },
  emitters: [{ role: 'ring', values: {}, families: [/* … */] }],
}

Give the replacement a role name of its own, since an override applies to every emitter with that role, appended ones included. The prefix is what names the class, so focus-ring under a role called ring keeps the name your components already use. Configuration covers the whole custom tree, including which paths are load-bearing.

Passthroughs

Token subtrees that map cleanly onto a native Tailwind namespace skip the emitter model entirely, which is how our own radius and shadow ship.

passthrough: [
  { namespace: '--radius', from: 'dimension.surface.radius.**', prefix: 'surface' },
  { namespace: '--shadow', from: 'boxShadow.**' },
]

We reach for one whenever Tailwind already owns the property, since arbitrary values, variants and completion then come for free.

Helpers

Static classes with no token behind them live in helpers, written as name to body. Your entries merge over Mirror’s single helper, scrollbar-none, and helpers: false drops every helper including that one.

export default extendMirrorConfig({
  source: { type: 'git', repo: 'magicasaservice/mirror-tokens' },
  utilities: {
    helpers: {
      'content-auto': 'content-visibility: auto;',
    },
  },
})

Each entry becomes one static @utility, so it works with every variant and needs no token. Keep it for the handful of classes every app writes by hand, since anything with a size or colour axis belongs in an emitter.

Limitations

There are two shapes the config cannot express.

The first is a set of rungs that differ in structure rather than in value. A missing value degrades correctly on its own, because an unresolvable --value() drops nothing but its own declaration, while a missing nested rule does not, and a rung without a border token would still get an empty ::before. Split those into a second family, which emits static blocks one per rung as soon as it has either filter, or accept the inert pseudo-element.

The second is anything outside the property and value model, so @media, @container, @keyframes, sibling selectors and :has() are all out of reach.

For both, the escape hatch is a raw CSS include.

export default extendMirrorConfig({
  source: { type: 'git', repo: 'magicasaservice/mirror-tokens' },
  utilities: {
    include: ['./app/assets/css/materials.css'],
  },
})

The generator concatenates those files unchanged and formats the result, and nothing in them is discovered, validated or rewritten. Use it for the one-offs, and move a family into emitters as soon as it has more than one rung.

Further reading