Quick Start
Install Mirror, generate the tokens and the stylesheet, then put a labelled, validated and styled field on screen.
<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.
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.