Skip to content

Quick Start

Install Mirror, generate the tokens and the stylesheet, then put a labelled, validated and styled field on screen.

View source View as Markdown

App.vue
<template>
  <form class="flex w-[17rem] flex-col gap-3" @submit.prevent="submit">
    <Field.Root
      class="group flex flex-col gap-1.5"
      :options="{ name: 'email', validate: validateEmail }"
    >
      <div :class="box">
        <div :class="stack">
          <span :class="line">
            <Input
              v-model="email"
              :class="control"
              type="email"
              placeholder=" "
              autocomplete="off"
              required
            />
          </span>
          <Label :class="label">Email</Label>
        </div>
      </div>

      <Field.Error v-slot="{ messages }" :class="error">
        {{ messages.join(' ') }}
      </Field.Error>
    </Field.Root>

    <Button :class="button" type="submit" :loading="saving">
      {{ saving ? 'Saving…' : 'Save' }}
    </Button>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Button, Field, Input, Label } from '@maas/mirror/vue'

const email = ref('')
const saving = ref(false)

const box = [
  'relative isolate flex h-12 w-full cursor-text items-center justify-between gap-1.5',
  'rounded-component-lg border-surface border-2 px-[0.875rem]',
  'transition-all duration-100 ease-linear [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
  'outline-4 outline-transparent focus-within:focus-ring',
  'group-[[data-invalid=true]:not([data-focused=true])]:border-danger-subtle',
  'group-[[data-invalid=true]:not([data-focused=true])]:bg-danger-subtle',
  'group-data-[disabled=true]:border-disabled-subtle group-data-[disabled=true]:cursor-not-allowed',
].join(' ')

const stack = [
  'group/stack flex h-full max-h-full w-full flex-col-reverse',
  'items-center justify-center gap-0 px-1',
  'focus-within:gap-1 has-[input:not(:placeholder-shown)]:gap-1',
].join(' ')

const line = [
  'relative flex h-0 w-full items-end',
  'group-focus-within/stack:h-[0.9375rem]',
  'has-[input:not(:placeholder-shown)]:h-[0.9375rem]',
].join(' ')

const control = [
  'type-component-lg leading-[normal]! text-surface',
  'block box-content h-[1lh] w-full py-1.25 -my-1.25 appearance-none bg-transparent outline-none',
  'placeholder:text-transparent',
  'group-[[data-invalid=true]:not([data-focused=true])]:text-danger-on-muted',
  'data-[disabled=true]:text-disabled-solid',
].join(' ')

const label = [
  'type-component-lg leading-[normal]! text-surface-muted',
  'flex h-full w-full items-center [--mirror-label-cursor:text]',
  "before:absolute before:-inset-0.5 before:-z-10 before:content-['']",
  'group-focus-within/stack:h-auto group-focus-within/stack:text-[0.6875rem] group-focus-within/stack:[--mirror-label-cursor:auto]',
  'group-has-[input:not(:placeholder-shown)]/stack:h-auto group-has-[input:not(:placeholder-shown)]/stack:text-[0.6875rem] group-has-[input:not(:placeholder-shown)]/stack:[--mirror-label-cursor:auto]',
  'group-[[data-invalid=true]:not([data-focused=true])]:text-danger-muted',
  'group-data-[disabled=true]:text-disabled-muted',
].join(' ')

const error = 'type-component-2xs text-danger-muted'

const button = [
  'inline-flex h-12 items-center justify-center gap-1.5 px-[1.125rem] whitespace-nowrap',
  'rounded-component-lg border-2 border-[transparent] type-component-lg',
  'bg-primary-solid text-primary-on-solid',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:bg-primary-solid-hover active:bg-primary-solid-active',
  'data-[loading=true]:bg-disabled-solid data-[loading=true]:text-disabled-on-solid',
].join(' ')

function validateEmail(value: unknown) {
  const entry = String(value ?? '')

  return entry.length === 0 || entry.includes('@')
    ? null
    : 'That does not look like an email address.'
}

async function submit() {
  saving.value = true
  await new Promise((resolve) => setTimeout(resolve, 1200))
  saving.value = false
}
</script>

The field above is what this page builds. Its label is properly associated, its error is wired to aria-describedby and validation runs on blur, and none of the appearance was decided by Mirror.

Install the package

pnpm add @maas/mirror

A few peer dependencies are needed at runtime as well, and without them Select and Combobox fail to resolve.

pnpm add @vueuse/core focus-trap @floating-ui/vue

Generate the tokens and the stylesheet

Mirror ships no compiled token file, so before the first build you run three commands: the first writes the config, the other two produce everything your app reads.

Terminal
pnpm mirror init
pnpm mirror tokens
pnpm mirror css

init writes a mirror.config.ts pointing at our token repository, which both of the other commands read. tokens compiles that source into .maas/tokens/, and css reads the result and writes .maas/tailwind.preset.css.

To edit the tokens rather than read ours, run mirror init --tokens instead of mirror init. It copies the token tree into ./tokens and writes a mirror.config.ts whose source reads that directory, so every later mirror tokens compiles files that are checked in with the project. Pass --from to copy a repository of your own instead of ours. See the CLI reference.

Nothing prompts and nothing is remembered between runs, so we run both build commands as part of every build rather than committing their output.

{
  "scripts": {
    "build": "mirror tokens && mirror css && nuxt build"
  }
}

Import the stylesheets

Import the generated preset into your Tailwind stylesheet, after Tailwind itself, since that is where all the Mirror class names come from.

@import 'tailwindcss';
@import '../../.maas/tailwind.preset.css';

Then load the token files. If you are using Nuxt, register the module and list the token files in nuxt.config.ts, and the module loads the component CSS for you.

export default defineNuxtConfig({
  modules: ['@maas/mirror/nuxt'],

  css: [
    '~~/.maas/tokens/css/application.css',
    '~~/.maas/tokens/css/theme/dark/application.css',
    '~~/assets/css/tailwind.css',
  ],
})

The module registers every Mirror export globally with an M prefix, so Select.Root becomes <m-select-root> and there is nothing to import. If your app already ships its own M* components, set mirror: { prefix: 'Mirror' }.

If you are using Vue on its own, import the token files and the component CSS once at your entry point. There is no plugin to install.

import '@maas/mirror/vue/index.css'
import '../.maas/tokens/css/application.css'
import '../.maas/tokens/css/theme/dark/application.css'
import './assets/css/tailwind.css'

Compose the parts

Field.Root owns the validity state, Field.Label points at the control, Input joins the field on its own, and Field.Error only renders while the field is invalid.

<script setup lang="ts">
import { Field, Input, Button } from '@maas/mirror/vue'

const email = ref('')

function validateEmail(value: unknown) {
  const entry = String(value ?? '')

  if (entry.length === 0) {
    return 'Enter an email address.'
  }

  return entry.includes('@') ? null : 'That does not look like an email address.'
}
</script>

<template>
  <form @submit.prevent="save">
    <Field.Root :options="{ name: 'email', validate: validateEmail }">
      <Field.Label>Email</Field.Label>
      <Input v-model="email" type="email" placeholder="[email protected]" />
      <Field.Error v-slot="{ messages }">{{ messages.join(' ') }}</Field.Error>
    </Field.Root>

    <Button type="submit">Save</Button>
  </form>
</template>

That already works, and it is completely unstyled.

Paint it with the generated classes

Whenever a part changes state it writes that state to a data-* attribute, which is what the classes below select on, so the stylesheet needs no JavaScript and no extra props. Use the generated utilities so every colour stays a token.

<template>
  <Field.Label
    class="type-component-sm text-surface-muted
      data-[invalid=true]:text-danger-muted"
  >
    Email
  </Field.Label>

  <Input
    v-model="email"
    type="email"
    class="rounded-component-md border border-surface bg-surface-high px-3 py-2
      type-component-sm text-surface
      focus-visible:focus-ring focus-visible:outline-none
      data-[invalid=true]:border-danger-subtle"
  />

  <Field.Error class="type-component-xs text-danger-muted" />
</template>

Pair a fill with its matching on-* colour: bg-primary-solid goes with text-primary-on-solid, and because that pairing is resolved in the token layer it survives every theme and both colour modes.

<template>
  <Button
    type="submit"
    class="rounded-component-md px-3.5 py-2 type-component-sm transition-colors
      bg-primary-solid text-primary-on-solid
      hover:bg-primary-solid-hover active:bg-primary-solid-active
      focus-visible:focus-ring focus-visible:outline-none
      data-[loading=true]:bg-primary-light
      data-[loading=true]:text-primary-muted"
  >
    Save
  </Button>
</template>

That is the field in the preview above, running in your app.

Further reading

  • Styling: data attributes, slot props, CSS variables and the generated utilities, and how the four combine.
  • Composition: wrap the class list into one component per control, and give it the appearance API your project talks about.
  • Theming: turn on dark mode and scope a theme to a subtree.