路由过渡动画与视图过渡

页面切换不应该是"闪白"——流畅的过渡动画让用户感知到空间关系、降低认知负担、提升产品品质感。Nuxt4 内置了完善的 CSS Transition 支持,更集成了浏览器原生的 View Transitions API,让跨页面的共享元素动画成为可能。本章从基础 CSS 过渡讲到原生 View Transitions,最终实现 AI 视频详情页的沉浸式转场效果。

1. NuxtPage 的 transition prop

1.1 全局页面过渡

nuxt.config.ts 中配置全局默认过渡动画:

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    pageTransition: {
      name: 'page',
      mode: 'out-in',  // 旧页面先离开,新页面再进入
    },
  },
})

对应的 CSS(在全局样式文件中定义):

/* app/assets/css/transitions.css */
 
/* 淡入淡出 */
.page-enter-active,
.page-leave-active {
  transition: opacity 0.3s ease;
}
 
.page-enter-from,
.page-leave-to {
  opacity: 0;
}

1.2 过渡模式

模式行为适用场景
out-in旧页面离开完毕后,新页面进入最常用,避免布局闪烁
in-out新页面先进入,旧页面再离开特殊效果(如覆盖动画)
默认(无 mode)新旧页面同时过渡滑动类动画

1.3 NuxtPage 上直接指定

<!-- app/app.vue -->
<template>
  <NuxtLayout>
    <NuxtPage
      :transition="{
        name: 'slide',
        mode: 'out-in',
      }"
    />
  </NuxtLayout>
</template>
/* 左滑入场 */
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease, opacity 0.3s ease;
}
 
.slide-enter-from {
  transform: translateX(30px);
  opacity: 0;
}
 
.slide-leave-to {
  transform: translateX(-30px);
  opacity: 0;
}

1.4 布局过渡

切换布局时也可以应用过渡动画:

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    layoutTransition: {
      name: 'layout',
      mode: 'out-in',
    },
  },
})
.layout-enter-active,
.layout-leave-active {
  transition: opacity 0.2s ease;
}
 
.layout-enter-from,
.layout-leave-to {
  opacity: 0;
}

2. 基于路由元数据的差异化过渡

2.1 不同页面使用不同过渡

通过 definePageMeta 为每个页面指定不同的过渡效果:

<!-- app/pages/index.vue — 首页:淡入淡出 -->
<script setup lang="ts">
definePageMeta({
  pageTransition: { name: 'fade', mode: 'out-in' },
})
</script>
 
<!-- app/pages/videos/[id].vue — 详情页:从右滑入 -->
<script setup lang="ts">
definePageMeta({
  pageTransition: { name: 'slide-right', mode: 'out-in' },
})
</script>
 
<!-- app/pages/(auth)/login.vue — 登录页:从底部弹入 -->
<script setup lang="ts">
definePageMeta({
  pageTransition: { name: 'slide-up', mode: 'out-in' },
})
</script>

对应 CSS:

/* 淡入 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
 
/* 右滑 */
.slide-right-enter-active, .slide-right-leave-active {
  transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-right-enter-from { transform: translateX(50px); opacity: 0; }
.slide-right-leave-to { transform: translateX(-50px); opacity: 0; }
 
/* 底部弹入 */
.slide-up-enter-active, .slide-up-leave-active {
  transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-up-enter-from { transform: translateY(30px); opacity: 0; }
.slide-up-leave-to { transform: translateY(-30px); opacity: 0; }

2.2 禁用特定页面的过渡

<script setup lang="ts">
definePageMeta({
  pageTransition: false,  // 该页面不使用过渡动画
})
</script>

2.3 动态过渡(根据导航方向)

根据前进/后退使用不同的过渡方向:

<!-- app/app.vue -->
<script setup lang="ts">
const route = useRoute()
const transitionName = ref('slide-left')
 
// 监听路由变化,判断方向
const routeDepth = (path: string) => path.split('/').filter(Boolean).length
 
watch(
  () => route.fullPath,
  (to, from) => {
    const toDepth = routeDepth(to)
    const fromDepth = routeDepth(from || '/')
    transitionName.value = toDepth >= fromDepth ? 'slide-left' : 'slide-right'
  }
)
</script>
 
<template>
  <NuxtLayout>
    <NuxtPage :transition="{ name: transitionName, mode: 'out-in' }" />
  </NuxtLayout>
</template>

2.4 JavaScript 钩子增强

对于需要 JavaScript 控制的复杂动画(如 GSAP、Motion One):

<script setup lang="ts">
definePageMeta({
  pageTransition: {
    name: 'custom',
    mode: 'out-in',
    onBeforeEnter: (el) => {
      el.style.opacity = '0'
      el.style.transform = 'scale(0.95)'
    },
    onEnter: (el, done) => {
      // 使用 Web Animations API 或 GSAP
      el.animate(
        [
          { opacity: 0, transform: 'scale(0.95)' },
          { opacity: 1, transform: 'scale(1)' },
        ],
        { duration: 300, easing: 'ease-out' }
      ).onfinish = done
    },
    onLeave: (el, done) => {
      el.animate(
        [
          { opacity: 1, transform: 'scale(1)' },
          { opacity: 0, transform: 'scale(0.95)' },
        ],
        { duration: 200, easing: 'ease-in' }
      ).onfinish = done
    },
  },
})
</script>

3. View Transitions API 在 Nuxt4 中的集成

3.1 什么是 View Transitions API

View Transitions API 是浏览器原生的页面过渡能力,允许在 DOM 更新前后创建快照动画,实现:

  • 整个页面的交叉淡入
  • 共享元素的跨页面动画(如缩略图→大图)
  • 无需手动管理 CSS 过渡类名

3.2 在 Nuxt4 中启用

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    viewTransition: true,  // 启用原生 View Transitions
  },
})

启用后,Nuxt 在每次客户端导航时会自动调用 document.startViewTransition()

3.3 默认效果

启用后,页面切换会自动获得交叉淡入效果(无需任何 CSS)。你可以通过 CSS 自定义这个效果:

/* 自定义 View Transition 默认动画 */
::view-transition-old(root) {
  animation: 0.3s ease-out fade-out;
}
 
::view-transition-new(root) {
  animation: 0.3s ease-in fade-in;
}
 
@keyframes fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}
 
@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

3.4 共享元素过渡

View Transitions API 的核心能力——让两个页面中的同一元素"飞"到新位置:

<!-- app/pages/videos/index.vue — 视频列表页 -->
<template>
  <div class="grid grid-cols-3 gap-4">
    <NuxtLink
      v-for="video in videos"
      :key="video.id"
      :to="`/videos/${video.id}`"
    >
      <img
        :src="video.thumbnail"
        :alt="video.title"
        :style="{ viewTransitionName: `video-${video.id}` }"
        class="rounded-lg"
      />
      <h3>{{ video.title }}</h3>
    </NuxtLink>
  </div>
</template>
<!-- app/pages/videos/[id].vue — 视频详情页 -->
<template>
  <div>
    <img
      :src="video?.thumbnail"
      :alt="video?.title"
      :style="{ viewTransitionName: `video-${route.params.id}` }"
      class="w-full rounded-lg"
    />
    <h1 class="mt-4 text-2xl font-bold">{{ video?.title }}</h1>
  </div>
</template>
 
<script setup lang="ts">
const route = useRoute()
const { data: video } = await useFetch(`/api/videos/${route.params.id}`)
</script>

关键:两个页面中的同一元素设置相同的 view-transition-name。浏览器会自动生成从列表缩略图"飞"到详情大图的动画。

3.5 自定义共享元素动画

/* 让视频缩略图的过渡更炫 */
::view-transition-old(video-*) {
  animation: 0.3s ease-out scale-down;
}
 
::view-transition-new(video-*) {
  animation: 0.3s ease-in scale-up;
}
 
@keyframes scale-down {
  from { transform: scale(1); }
  to { transform: scale(0.8); opacity: 0; }
}
 
@keyframes scale-up {
  from { transform: scale(0.8); opacity: 0; }
  to { transform: scale(1); }
}

3.6 兼容性处理

View Transitions API 目前支持 Chrome 111+、Edge 111+,Safari 和 Firefox 暂不支持。Nuxt4 会自动检测:

// Nuxt 内部逻辑(简化)
if (document.startViewTransition) {
  document.startViewTransition(() => { /* 执行导航 */ })
} else {
  // 降级到普通导航(无动画或使用 CSS Transition 回退)
}

你不需要手动处理兼容性——不支持的浏览器会直接跳过。

4. AI 视频详情页共享元素过渡实战

4.1 效果目标

视频列表页                        视频详情页
┌─────────────────┐              ┌─────────────────────────┐
│ ┌───┐ ┌───┐    │              │ ┌─────────────────────┐ │
│ │ A │ │ B │    │  点击 B →    │ │                     │ │
│ └───┘ └───┘    │  缩略图      │ │     视频 B 大图      │ │
│ ┌───┐ ┌───┐    │  飞向大图    │ │                     │ │
│ │ C │ │ D │    │              │ └─────────────────────┘ │
│ └───┘ └───┘    │              │  视频 B 标题...          │
└─────────────────┘              └─────────────────────────┘

4.2 完整实现

<!-- app/components/VideoCard.vue -->
<script setup lang="ts">
interface Props {
  video: {
    id: string
    title: string
    thumbnail: string
    duration: number
  }
}
 
const props = defineProps<Props>()
</script>
 
<template>
  <NuxtLink
    :to="`/videos/${video.id}`"
    class="group block overflow-hidden rounded-xl"
  >
    <div class="relative aspect-video overflow-hidden">
      <img
        :src="video.thumbnail"
        :alt="video.title"
        :style="{ viewTransitionName: `video-thumb-${video.id}` }"
        class="w-full h-full object-cover transition-transform group-hover:scale-105"
      />
      <span class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded">
        {{ formatDuration(video.duration) }}
      </span>
    </div>
    <h3
      :style="{ viewTransitionName: `video-title-${video.id}` }"
      class="mt-2 text-sm font-medium line-clamp-2"
    >
      {{ video.title }}
    </h3>
  </NuxtLink>
</template>
<!-- app/pages/videos/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: video } = await useFetch(`/api/videos/${route.params.id}`)
</script>
 
<template>
  <div v-if="video" class="max-w-4xl mx-auto">
    <div class="aspect-video rounded-xl overflow-hidden bg-black">
      <img
        :src="video.thumbnail"
        :alt="video.title"
        :style="{ viewTransitionName: `video-thumb-${video.id}` }"
        class="w-full h-full object-cover"
      />
    </div>
 
    <h1
      :style="{ viewTransitionName: `video-title-${video.id}` }"
      class="mt-4 text-2xl font-bold"
    >
      {{ video.title }}
    </h1>
 
    <p class="mt-2 text-gray-600">{{ video.description }}</p>
  </div>
</template>

4.3 动画微调

/* app/assets/css/view-transitions.css */
 
/* 缩略图:带缩放的过渡 */
::view-transition-group(video-thumb-*) {
  animation-duration: 0.35s;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
 
/* 标题:淡入淡出 + 位移 */
::view-transition-old(video-title-*) {
  animation: 0.2s ease-out forwards fade-slide-out;
}
 
::view-transition-new(video-title-*) {
  animation: 0.3s ease-in 0.1s forwards fade-slide-in;
}
 
@keyframes fade-slide-out {
  to { opacity: 0; transform: translateY(-10px); }
}
 
@keyframes fade-slide-in {
  from { opacity: 0; transform: translateY(10px); }
}
 
/* 减少动画偏好的用户:禁用动画 */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

本章小结

  • CSS 页面过渡pageTransition + .&#123;name&#125;-enter/leave-active/from/to 类名
  • 过渡模式out-in(最常用)、in-out、默认(同时过渡)
  • 差异化过渡:通过 definePageMeta 为每个页面指定不同效果
  • 动态过渡:根据导航方向切换动画方向
  • View Transitions APIviewTransition: true 一行启用,浏览器原生支持
  • 共享元素view-transition-name 匹配两个页面中的同一元素,自动生成飞行动画
  • 无障碍prefers-reduced-motion 媒体查询尊重用户偏好

下一章是路由篇的最后一章——程序化路由与深链接设计。