Styling
Read this to style a Mirror component from its data attributes, slot props, public CSS variables and generated utility classes.
Mirror’s components don’t come with a look. They render the markup, keep track of their state and tell you about it, and leave everything visual to you. In practice there are four places where that happens: data attributes, slot props, CSS variables and the generated utility classes.
Import the component CSS
The Nuxt module loads @maas/mirror/vue/index.css for you; without Nuxt,
import it once at your entry point. It is around 6 KB of the geometry the parts
cannot express in markup: slider thumb positions, progress width, the hidden
inputs, popup sizing against the anchor, the tabs indicator and the
textarea auto-size box.
import '@maas/mirror/vue/index.css'
Without it most components still look fine, but the slider thumbs pile up in one corner and popups size to their content rather than their trigger, so import it before anything else.
Those rules hang off a class every part renders: its own name, kebab cased and
prefixed with mirror-. Select.Trigger is .mirror-select-trigger, and a
Root takes the component name on its own, so Avatar.Root is
.mirror-avatar. It is always the first class on the element, ahead of anything
you pass through class, so a part is easy to recognise in the inspector.
Style from data attributes
Whenever a part changes state, it writes that state to a data-* attribute on
its element. This is what we style against, both inside Mirror and in our own
apps, which is also why none of the components take a variant or color
prop.
Boolean states are written as the string true and removed entirely when
false, so selectors should check the value rather than the mere presence of the
attribute.
.checkbox[data-disabled='true'] {
opacity: 0.5;
}
In a template, the Tailwind variant is the same selector.
<template>
<Checkbox.Root
class="border-surface data-[disabled=true]:opacity-50
data-[state=checked]:bg-primary-solid"
/>
</template>
Enumerated state is written as a word. data-state holds checked,
unchecked, indeterminate, open, closed, on or off depending on the
part, and data-status, data-orientation, data-side and data-align hold
the rest. Each component page lists what its parts write.
The field state set
A control’s relationship to its Field is described by a set of state
attributes. Every control writes all of them and so does every Field part, so
you can style the label from the control’s validity without a wrapper class.
Where a component page says “the field state set”, it means these, and only the additions are listed per part.
Outside a Field, a control writes the ones it can work out on its own, which
are data-disabled, data-readonly, data-required, data-filled and
data-focused. The validity attributes are left off rather than guessed.
data-scope is only written by the parts whose role is fixed, so a control
that could sit in any scope is left without one.
[data-scope='label'][data-invalid='true'] {
color: var(--app-color-danger-fg-muted);
}
Read the same state as slot props
Data attributes are enough when the difference is a declaration. When you need
different elements, read the same state from the slot, where the names are
the same minus the data- prefix and in camelCase.
<template>
<Checkbox.Root v-slot="{ checked, indeterminate, disabled }">
<icon :name="indeterminate ? 'dash' : checked ? 'check' : 'blank'" />
</Checkbox.Root>
</template>
data-readonly arrives as readOnly. A few parts shorten a name and say so in
their own table, as Select.Trigger does when it writes data-popup-open and
hands the slot open.
Override the public CSS variables
Where a shipped value is a taste decision rather than a mechanism, the component reads it from a public variable with a fallback that doubles as the default.
.mirror-slider-thumb {
block-size: var(--mirror-slider-thumb-size, 1rem);
}
Set it on :root to move every instance in the app, or on any ancestor
element to move only the parts inside that subtree.
:root {
--mirror-slider-thumb-size: 1.25rem;
--mirror-slider-track-size: 0.375rem;
}
Public variables are named --mirror-<component>[-<part-or-state>]-<property>
and are documented one for one on each component page.
A few of them work the other way round: the component writes them and you read
them to position your own decoration. The Select, Combobox and
Autocomplete floating boxes write --rack-floating-anchor-width and
--rack-floating-available-height, and setting either has no effect.
.select-popup {
min-inline-size: var(--rack-floating-anchor-width);
}
Variables under a short prefix (--mpr-, --msl-, --mta-, --mtb-) are
internal to the component. Read one where a page tells you it is useful, as
Progress does with --mpr-percentage, and never set one.
Paint with the generated utilities
Colour, type, radius and shadow come from the classes mirror css generates. A
class exists because a token exists, so every value you apply stays themeable.
<template>
<Field.Label class="type-component-sm text-surface-muted">Email</Field.Label>
</template>
Pair a fill with its matching on-* colour: bg-primary-solid goes with
text-primary-on-solid. See Utilities for the families.
Bring your own styling layer
Everything above is plain CSS surface: a stable class on every part, state in data attributes, defaults in custom properties. None of it assumes Tailwind, so any tool that can target a selector can style a Mirror component.
Scoped SFC styles address the part class directly, and state stays an attribute
selector, since the components toggle no state classes. In a CSS Module, wrap
the part class in :global(): a module hashes every class name it declares, so
a bare .mirror-select-trigger compiles to a name nothing on the page carries.
<style scoped>
.mirror-select-trigger {
border: 2px solid var(--mirror-border-surface);
}
.mirror-select-trigger[data-popup-open='true'] {
border-color: var(--mirror-border-primary-subtle);
}
</style>
A runtime CSS-in-JS library works the same way: generate a class, pass it
through class, and write the state rules against the data-* attributes
rather than swapping classes from script.
Whichever layer you pick, take colour, type, radius and shadow from the
--mirror-* token variables instead of literal values. A literal is correct
in one theme and one colour mode; the variable is correct in all of them,
which is the same guarantee the generated utilities give you.
How the four combine
Most controls end up using all four at once. The utilities supply the palette,
the data-* variants switch between values in it, a public variable sizes what
markup cannot, and the slot decides what gets rendered.
<template>
<Checkbox.Root
class="size-5 rounded-component-2xs border-2 border-surface bg-surface-high
transition-colors
data-[state=checked]:bg-primary-solid
data-[state=indeterminate]:bg-primary-muted
data-[disabled=true]:opacity-50
outline-4 outline-transparent focus-visible:focus-ring"
v-slot="{ checked, indeterminate }"
>
<Checkbox.Indicator class="text-primary-on-solid">
<icon :name="indeterminate ? 'dash' : 'check'" v-if="checked || indeterminate" />
</Checkbox.Indicator>
</Checkbox.Root>
</template>
Reserve the ring before focus arrives. focus-ring sets a border width and an
outline width as well as their colours, so a control that has neither at rest
grows the moment it takes focus, and animates its outline out of whatever
currentColor happens to be, which reads as a white flash in dark mode. Give
every element that receives focus-ring a border-2 and an
outline-4 outline-transparent at rest, so focus changes colour and nothing
else.
Further reading
- Composition: wrap that class list into one component per control.
- Animation: the transition names, and where timing lives.