View transition

Animates a change to the page: elements glide to their new places, or a new look sweeps in from a point.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add view-transition
pnpm dlx ermineui add view-transition
yarn dlx ermineui add view-transition
bunx ermineui add view-transition

That copies 4 files into src/components/ui/view-transition/. Files you already have are kept.

Use

import {
  vTransitionName,
  revealTransition,
  supportsViewTransitions,
  viewTransition,
} from '@/components/ui/view-transition'

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
directive.tssrc/components/ui/view-transition/directive.ts
index.tssrc/components/ui/view-transition/index.ts
transition.tssrc/components/ui/view-transition/transition.ts
view-transition.csssrc/components/ui/view-transition/view-transition.css

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

directive.ts 20 lines
import type { Directive } from 'vue'

/**
 * v-transition-name — marks an element as "the same thing" before and after a change, so that
 * viewTransition() glides it from its old place to its new one.
 *
 *   <li v-for="item in items" :key="item.id" v-transition-name="`item-${item.id}`">…</li>
 *
 * Each name must belong to only one element on the page at a time.
 */
const apply = (el: HTMLElement, name: string | undefined | null) => {
  // Names are CSS identifiers: anything else in them is replaced, so ids with dots or slashes still work.
  el.style.setProperty('view-transition-name', name ? `er-${String(name).replace(/[^\w-]/g, '-')}` : 'none')
}

export const vTransitionName: Directive<HTMLElement, string | undefined | null> = {
  mounted: (el, binding) => apply(el, binding.value),
  updated: (el, binding) => apply(el, binding.value),
}
index.ts 6 lines
/** View transition — animates a change to the page: elements glide to their new places, or a new look sweeps in from a point. */
import './view-transition.css'

export { vTransitionName } from './directive'
export { revealTransition, supportsViewTransitions, viewTransition } from './transition'
transition.ts 76 lines
import { nextTick } from 'vue'

/**
 * The View Transitions API takes a picture of the page, lets you change it, takes another, and
 * animates from one to the other. Elements that carry the same view-transition-name in both
 * pictures glide from their old place to their new one. Where the browser does not support it,
 * or the reader has asked for reduced motion, the change simply happens at once.
 */

type TransitionDocument = Document & {
  startViewTransition?: (update: () => void | Promise<void>) => { ready: Promise<void>; finished: Promise<void> }
}

/** True where the browser can animate between two states of the page. */
export function supportsViewTransitions(): boolean {
  return typeof document !== 'undefined' && typeof (document as TransitionDocument).startViewTransition === 'function'
}

const calm = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches

/**
 * Runs `update` (which changes your state) and animates the page from how it looked before to how
 * it looks after. Resolves when the animation is over.
 *
 *   viewTransition(() => { items.value = shuffle(items.value) })
 */
export async function viewTransition(update: () => void | Promise<void>): Promise<void> {
  if (!supportsViewTransitions() || calm()) {
    await update()
    return
  }
  const transition = (document as TransitionDocument).startViewTransition!(async () => {
    await update()
    // Vue redraws a tick later; the "after" picture has to wait for that.
    await nextTick()
  })
  // Rejects if the transition is skipped (another one started, the tab was hidden). The change still happened.
  await transition.finished.catch(() => {})
}

/**
 * Like viewTransition, but the new look is uncovered by a circle growing from a point: usually
 * the button that was pressed. Made for switching theme.
 *
 *   revealTransition(() => toggleTheme(), event)
 */
export async function revealTransition(update: () => void | Promise<void>, origin?: { clientX: number; clientY: number } | { x: number; y: number }): Promise<void> {
  if (!supportsViewTransitions() || calm()) {
    await update()
    return
  }
  const x = origin ? ('clientX' in origin ? origin.clientX : origin.x) : window.innerWidth / 2
  const y = origin ? ('clientY' in origin ? origin.clientY : origin.y) : window.innerHeight / 2
  // Far enough to reach the furthest corner from the starting point.
  const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y))

  const root = document.documentElement
  root.dataset.erReveal = ''
  const transition = (document as TransitionDocument).startViewTransition!(async () => {
    await update()
    await nextTick()
  })
  try {
    await transition.ready
    root.animate(
      { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
      { duration: 450, easing: 'ease-in-out', pseudoElement: '::view-transition-new(root)' },
    )
    await transition.finished
  } catch {
    // Skipped: the change still happened, just without the animation.
  } finally {
    delete root.dataset.erReveal
  }
}
view-transition.css 12 lines
/* While a circular reveal runs, the browser's default cross-fade is switched off: the new look is
   uncovered by a growing circle instead (see revealTransition). */
html[data-er-reveal]::view-transition-old(root),
html[data-er-reveal]::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

html[data-er-reveal]::view-transition-new(root) {
  z-index: 1;
}