Skip to content

Composition

Change the rendered element with as and asChild, wrap a part once, and extend the props interfaces it exports.

View source View as Markdown

Every part takes as and asChild, and every props interface is exported, so you can put a Mirror part inside a component of your own without restating what it already accepts.

Import a part

Every part is exported twice, as a flat component and as a member of a namespace object. Templates use the dotted form, because it reads as the anatomy and it survives a rename.

<script setup lang="ts">
import { Select } from '@maas/mirror/vue'
</script>

<template>
  <Select.Root>
    <Select.Trigger />
  </Select.Root>
</template>

Single-part components are the component itself: <Button>, never <Button.Root>. A namespace therefore means the component has more than one part, so an import tells you how many pieces to expect before you read the template. Field.Label is the one member that is not its own component, since it is Label re-exported so that a field’s anatomy reads in one piece.

If you are using Nuxt, the module registers every export globally with an M prefix, so the same parts are <m-select-root> and <m-select-trigger> with nothing to import.

Swap the element with as

as changes the element or component a part renders, keeping the behaviour and the state attributes.

<template>
  <Button as="a" href="/docs">Read the docs</Button>
</template>

Hand the behaviour to a child with asChild

asChild renders no element of its own. It merges everything it would have rendered, attributes, listeners and data-* state, onto the single child you provide.

<template>
  <Button as-child>
    <nuxt-link to="/settings">Settings</nuxt-link>
  </Button>
</template>

Give it exactly one element child, because everything merges onto the first element it finds and any further siblings render without attributes, listeners or state.

Configure a part through options

A component that owns state takes a bundled options object on its root rather than a long prop list, so positioning, dismissal, focus and transition settings sit in one place instead of being repeated across the parts that use them.

<template>
  <Popover.Root
    :options="{
      floating: { side: 'bottom', sideOffset: 8 },
      dismiss: { escape: false },
    }"
  />
</template>

A direct prop on a nested part is the escape hatch and wins over the matching key in the object. Reach for one where a single instance needs a different value, and leave the rest on the root. Each component page lists which of its props override which option.

Controlled and uncontrolled

Every part that owns a value takes the same pair. modelValue makes it controlled, defaultValue seeds it and leaves it uncontrolled, and v-model is the controlled form written short.

<template>
  <Switch.Root :default-value="true" />

  <Switch.Root v-model="notifications" />
</template>

Open state follows the same rule where a component has one: open with update:open, or defaultOpen.

Which of the two applies is decided once, during setup, by whether modelValue is undefined at that moment. A ref that starts out undefined and is filled in later leaves the part uncontrolled, and later writes to that ref do not move it.

// wrong: undefined at mount, so the part never becomes controlled
const value = ref<string | undefined>()

// right: give it an initial value, or key the component so it remounts
const value = ref('')

Wrap a part once

Once a class list repeats at every call site, it belongs in a wrapper. Put it in one component per control, such as app/components/AppButton.vue, and give that component the props your project wants to talk in, such as the intent below.

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

interface AppButtonProps {
  intent?: 'primary' | 'danger'
}

const { intent = 'primary' } = defineProps<AppButtonProps>()
</script>

<template>
  <Button class="app-button" :class="`-${intent}`">
    <slot />
  </Button>
</template>

The wrapper is where a prop matrix belongs. Mirror ships no variant, mode or size prop and will not add one, so declare yours here, where the names can be the ones your project already uses. Changing one is then an edit to your own code rather than a request to ours, and it reaches every call site at once.

Extend the exported props

Every part exports a props interface named after it, so a wrapper extends rather than duplicates.

import type { ButtonProps, SelectTriggerProps } from '@maas/mirror/vue'

interface AppButtonProps extends ButtonProps {
  intent?: 'primary' | 'danger'
}

interface AppSelectTriggerProps extends SelectTriggerProps {
  tone?: 'neutral' | 'danger'
}

Forward the rest with v-bind, so a prop the part gains in a later release reaches it through the wrapper with no edit to the wrapper.

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

const { intent = 'primary', ...rest } = defineProps<AppButtonProps>()
</script>

<template>
  <Button v-bind="rest" class="app-button" :class="`-${intent}`">
    <slot />
  </Button>
</template>

Render your own element polymorphically

Primitive and PrimitiveSlot are re-exported from @maas/mirror/vue, so a wrapper of your own can take as and asChild the same way a Mirror part does, without a second package.

<script setup lang="ts">
import { Primitive, type PrimitiveProps } from '@maas/mirror/vue'

const { as = 'div', asChild } = defineProps<PrimitiveProps>()
</script>

<template>
  <primitive :as="as" :as-child="asChild" class="app-panel">
    <slot />
  </primitive>
</template>

Reach a module from outside its subtree

A module keeps its state in an app-scoped store keyed by id, so anything in the app can drive it. Pass an id and read it back with the matching composable.

<script setup lang="ts">
import { useMirrorSelect } from '@maas/mirror/vue'

const { isOpen, open, close, toggle, value, setValue } =
  useMirrorSelect('framework')
</script>

Give a module an id only when something outside its subtree has to address it. See TypeScript for the full list of composables and the types they return.

Text direction

Every component that reacts to the horizontal arrow keys takes a dir option, but you rarely want to repeat it on each of them. Wrap the app, or the part of it that runs right to left, in a DirectionProvider instead and they all follow.

<script setup lang="ts">
import { DirectionProvider, Tabs } from '@maas/mirror/vue'
</script>

<template>
  <DirectionProvider direction="rtl">
    <Tabs.Root>…</Tabs.Root>
  </DirectionProvider>
</template>

The provider renders nothing and sets no attribute, so dir="rtl" on the <html> element or on whichever wrapper you own is still yours to set. If you already set it, you can skip the provider altogether: a component without a dir option reads the closest dir attribute above it once it is mounted. In full, a component takes its own dir option first, then the nearest DirectionProvider, then the closest dir attribute, and defaults to ltr.

The attribute has three blind spots the provider covers, so reach for the provider when any of them applies to you. An attribute lives in the DOM and can only be read after mount, so a server render and the first client paint fall back to left to right; the provider answers while the markup is being rendered. A portalled popup moves to the end of <body>, out of whichever wrapper carries your attribute, while the provider follows the component tree and reaches it anyway. And the attribute is read once on mount, while the provider is reactive, so changing its direction at runtime turns every component below it around.

The same resolution is available on its own through useDirection, which returns a computed 'ltr' | 'rtl' for a component of yours.

Further reading

  • Styling: the state a part writes, and the four ways to read it.
  • TypeScript: the props interfaces, the options types and the composables, listed by name.