Extending
Add your own utility families in mirror.config.ts, and narrow or drop ours by role rather than by class name.
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
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.
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.
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
- Configuration: the token half of the same file.
- Utilities: what the generator ships by default.