Skip to content

Styling

Read this to style a Mirror component from its data attributes, slot props, public CSS variables and generated utility classes.

View source View as Markdown

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.

AttributeDescription
data-disabledThe control or its field is disabled.
data-readonlyThe control is read-only.
data-requiredThe control is required.
data-validValidation has run and passed.
data-invalidValidation has run and failed, or invalid was set explicitly.
data-dirtyThe value has changed from its initial value.
data-touchedThe control has been focused and blurred at least once.
data-filledThe value is non-empty.
data-focusedThe control currently holds focus.
data-scopeThe part’s role in the field. One of label, control, description or error.

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.