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 initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add view-transitionpnpm dlx ermineui add view-transitionyarn dlx ermineui add view-transitionbunx ermineui add view-transitionThat 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
| File | Where it goes |
|---|---|
directive.ts | src/components/ui/view-transition/directive.ts |
index.ts | src/components/ui/view-transition/index.ts |
transition.ts | src/components/ui/view-transition/transition.ts |
view-transition.css | src/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;
}