Skip to content

Animation

Mirror parts hand you a transition name and leave the timing to your CSS. Read this for the names and for what stays in the DOM.

View source View as Markdown

Parts that appear and disappear hand you a Vue transition name, and all the timing lives in your CSS under that name, so a duration is never a prop.

.mirror-select-content-enter-active,
.mirror-select-content-leave-active {
  transition: opacity 120ms ease-out;
}

.mirror-select-content-enter-from,
.mirror-select-content-leave-to {
  opacity: 0;
}

The transition names

PartWhere it is setDefault
Checkbox.Indicatoroptions.transition on Checkbox.Rootmirror-checkbox-indicator
Radio.IndicatorThe transition prop on Radio.Indicatormirror-radio-indicator
Select.Contentoptions.transition.content on Select.Rootmirror-select-content
Select.Contentoptions.transition.backdrop on Select.Rootmirror-select-backdrop
Combobox.Contentoptions.transition.content on Combobox.Rootmirror-combobox-content
Combobox.Contentoptions.transition.backdrop on Combobox.Rootmirror-combobox-backdrop
Autocomplete.Contentoptions.transition.content on Autocomplete.Rootmirror-autocomplete-content
Autocomplete.Contentoptions.transition.backdrop on Autocomplete.Rootmirror-autocomplete-backdrop
Popover.Contentoptions.transition.content on Popover.Rootnone
Popover.Contentoptions.transition.backdrop on Popover.Rootnone
Tooltip.Contentoptions.transition.content on Tooltip.Rootmirror-tooltip-content
Tooltip.Contentoptions.transition.backdrop on Tooltip.Rootmirror-tooltip-backdrop
PreviewCard.Contentoptions.transition.content on PreviewCard.Rootnone
PreviewCard.Contentoptions.transition.backdrop on PreviewCard.Rootnone
Dialog.Contentoptions.transition.content on Dialog.Rootnone
Dialog.Backdropoptions.transition.backdrop on Dialog.Rootnone
AlertDialog.Contentoptions.transition.content on AlertDialog.Rootnone
AlertDialog.Backdropoptions.transition.backdrop on AlertDialog.Rootnone
Collapsible.Contentoptions.transition on Collapsible.Rootnone
Avatar.Imageoptions.transition on Avatar.Rootnone
Tabs.ContentThe transition prop on Tabs.Contentnone
ScrollArea.ScrollbarThe transition prop on ScrollArea.Scrollbarnone

Pass your own name to use your own classes.

<template>
  <Select.Root :options="{ transition: { content: 'app-popup' } }">
    <!-- … -->
  </Select.Root>
</template>

Popover, PreviewCard, Dialog, AlertDialog, Collapsible, Avatar, Tabs and ScrollArea ship no default name, so those popups appear and vanish, panels swap and scrollbars leave instantly until you pass one.

What unmounts

A part that is not present is removed from the DOM, so a checkbox indicator exists only while the box is checked or indeterminate, a select popup only while it is open, and Field.Error only while the field is invalid.

While they are mounted, popups and panels also write data-state as open or closed, so a CSS animation can read the state directly instead of going through a transition class.

.app-popup[data-state='closed'] {
  animation: fade-out 120ms ease-in;
}

Keeping a part mounted

forceMount keeps a part in the DOM permanently. Reach for it when something outside Mirror has to measure the node, or when your animation library owns the enter and leave itself.

<template>
  <Select.Content force-mount class="data-[state=closed]:pointer-events-none" />
</template>

Set options.forceMount on the root and it answers for every part under that root at once. On Select, Combobox and Autocomplete the option is keyed by part instead, so you write options.forceMount.popup rather than a plain boolean.

Where a part also takes a forceMount prop, the prop wins whenever you set it, false included, so one instance can stay unmounted under a root that keeps everything else. Leave the prop off and the option answers. A part that appears more than once under one root takes the prop alone, with no option behind it, and defaults to false: Field.Error, Radio.Indicator, Tabs.Content and ScrollArea.Scrollbar.

The parts that take it: Radio.Indicator, Field.Error, Select.Content, Combobox.Content, Autocomplete.Content, Popover.Content, Tooltip.Content, PreviewCard.Content, Dialog.Content, Dialog.Backdrop, AlertDialog.Content, AlertDialog.Backdrop, Collapsible.Content, Tabs.Content, ScrollArea.Scrollbar, Select.ItemIndicator and Combobox.ItemIndicator. Checkbox.Indicator is not among them: one root has one indicator, so options.forceMount on Checkbox.Root answers alone.

A kept panel also leaves the accessibility tree: an inactive kept panel is rendered with the hidden attribute, while a panel that is currently leaving is not, so its transition can finish.

<template>
  <Tabs.Content force-mount value="tokens">…</Tabs.Content>
</template>

Listening to a transition

The hooks of every named transition are broadcast on the app-wide emitter, so code that renders none of the part can still wait for it to have finished entering or leaving. Each event carries the instance id and the part.

import { useMirrorEmitter } from '@maas/mirror/vue'

const emitter = useMirrorEmitter()

emitter.on('select:afterEnter', ({ id, part }) => {
  if (id === SelectId.Country && part === 'content') {
    scrollSelectedIntoView()
  }
})

The hooks fire only while a name is set, so a part in the table above whose default is none broadcasts nothing until you name its transition. Emitter lists every event and its payload.

Respect reduced motion

Timing lives in your CSS, so the media query does too. Write it next to the transition it switches off.

@media (prefers-reduced-motion: reduce) {
  .mirror-select-content-enter-active,
  .mirror-select-content-leave-active {
    transition: none;
  }
}

Further reading

  • Styling: the state attributes an animation selects on.
  • Emitter: the transition hooks as app-wide events.
  • Accessibility: what else stays your job.