CSS View Transitions API 实战:页面过渡动画新范式

页面切换时的过渡动画,一直是前端的痛点。要么用各种 JS 动画库手动管理生命周期,要么干脆放弃——白屏闪烁就白屏闪烁吧。View Transitions API 改变了这一切:浏览器原生支持,一行代码触发,自动处理新旧状态的快照和动画。

本文从核心概念到跨页面导航、SPA 集成、列表动画,带你完整掌握 View Transitions API。

浏览器支持:Chrome 111+、Edge 111+ 已完整支持。Firefox 和 Safari 正在实现中。本文所有代码均基于最新稳定版 API,并附带渐进增强方案。

一、传统 CSS 动画痛点 vs View Transitions API

在 View Transitions API 出现之前,页面过渡动画的实现方式大致有三种:

方案 优点 痛点
CSS transition/animation 原生、高性能 只适合单个元素;跨页面无法使用;需手动管理状态
JS 动画库(GSAP、Framer Motion) 功能强大、生态丰富 包体积大;手动编排复杂;跨页面动画仍需大量胶水代码
SPA 路由动画 路由级别可控 框架绑定;MPA 不适用;旧 DOM 已销毁无法做过渡

核心痛点一句话概括:DOM 更新和动画是割裂的。你先更新 DOM(旧节点销毁、新节点创建),再想办法做动画。但旧节点已经没了,怎么从旧状态过渡到新状态?

View Transitions API 的解法:浏览器帮你拍照。更新 DOM 之前,浏览器给旧状态拍一张快照;更新之后,再给新状态拍一张。然后用 CSS 动画把两张快照连起来——你只需要告诉浏览器"更新 DOM",过渡动画自动生成。

二、核心概念

1. document.startViewTransition()

整个 API 的入口就一个函数:

document.startViewTransition(updateCallback);

updateCallback 是一个函数,在里面做 DOM 更新。浏览器会按以下流程执行:

1. 调用 startViewTransition()
2. 浏览器截取当前页面的视觉快照(old state)
3. 执行 updateCallback,更新 DOM
4. 浏览器截取更新后的视觉快照(new state)
5. 构造伪元素树,用动画从 old → new 过渡
6. 动画结束,清理伪元素
关键理解updateCallback 返回 Promise 时,浏览器会等 Promise resolve 后再截取新快照。这意味着你可以在回调中做异步数据加载。

2. 伪元素树

过渡期间,浏览器会插入以下伪元素树:

::view-transition
└── ::view-transition-group(root)
    └── ::view-transition-image-pair(root)
        ├── ::view-transition-old(root)    ← 旧快照
        └── ::view-transition-new(root)    ← 新快照

每个伪元素的职责:

伪元素 职责 默认动画
::view-transition 顶层容器,覆盖整个视口
::view-transition-group 管理一组过渡的定位和尺寸 从旧位置/尺寸动画到新位置/尺寸
::view-transition-image-pair 容纳 old 和 new 快照,负责隔离 isolation: isolate
::view-transition-old 旧状态的截图(<img> 或截图) opacity 1→0(淡出)
::view-transition-new 新状态的截图 opacity 0→1(淡入)
重要:过渡期间,真实的 DOM 已经更新完毕,只是被伪元素层盖住了。用户看到的是动画(快照之间的过渡),动画结束后伪元素消失,露出真实 DOM。这意味着 DOM 交互在过渡期间是被遮挡的——但你可以通过 CSS 控制 pointer-events 来调整。

三、基础用法

1. 最简单的页面过渡

// 点击按钮切换主题
document.querySelector('.theme-btn').addEventListener('click', () => {
  // ✅ 只需包裹一层 startViewTransition
  document.startViewTransition(() => {
    document.documentElement.classList.toggle('dark');
  });
});

就这么简单。浏览器自动做淡入淡出,不需要写任何 CSS 动画。

2. 自定义淡入淡出

默认的淡入淡出是 250ms,你可以通过 CSS 修改:

/* 修改默认过渡时长 */
::view-transition-old(root) {
  animation: 0.4s ease-in fade-out;
}

::view-transition-new(root) {
  animation: 0.4s ease-out fade-in;
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes fade-in {
  from { opacity: 0; }
}

3. 禁用默认过渡

/* 完全禁用默认的 root 过渡 */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
}

这在你只想对特定元素做过渡(而非整个页面)时非常有用——先禁用 root,再对特定 group 启用。

四、自定义动画

1. view-transition-name:给元素命名

view-transition-name 是整个自定义动画的关键。给元素设置这个名字后,它会在伪元素树中获得独立的 group,不再跟随 root 一起过渡。

/* 头像元素独立过渡 */
.hero-avatar {
  view-transition-name: hero-avatar;
}

/* 此时伪元素树变成:
::view-transition
├── ::view-transition-group(root)        ← 其他所有内容
└── ::view-transition-group(hero-avatar) ← 头像独立过渡
    └── ::view-transition-image-pair(hero-avatar)
        ├── ::view-transition-old(hero-avatar)
        └── ::view-transition-new(hero-avatar)
*/
规则view-transition-name 必须在同一时刻唯一。如果两个可见元素设置了相同的 name,过渡会跳过。动态设置时务必确保唯一性。

2. 修改动画时长和关键帧

/* 头像过渡:缩放 + 位移 */
::view-transition-group(hero-avatar) {
  animation-duration: 0.5s;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

/* 旧头像:缩小淡出 */
::view-transition-old(hero-avatar) {
  animation: 0.5s ease both shrink-out;
}

/* 新头像:放大淡入 */
::view-transition-new(hero-avatar) {
  animation: 0.5s ease both grow-in;
}

@keyframes shrink-out {
  to {
    opacity: 0;
    transform: scale(0.8);
  }
}

@keyframes grow-in {
  from {
    opacity: 0;
    transform: scale(1.2);
  }
}

3. 多个元素独立过渡

/* 列表中的每张卡片独立过渡 */
.card:nth-child(1) { view-transition-name: card-1; }
.card:nth-child(2) { view-transition-name: card-2; }
.card:nth-child(3) { view-transition-name: card-3; }

/* 所有卡片共享同一动画,但各自独立执行 */
::view-transition-group(card-1),
::view-transition-group(card-2),
::view-transition-group(card-3) {
  animation-duration: 0.3s;
}
动态命名:在 JS 中根据数据动态设置 view-transition-name,比在 CSS 中写 nth-child 更灵活:
document.querySelectorAll('.card').forEach((card, i) => {
  card.style.viewTransitionName = `card-${card.dataset.id}`;
});

五、跨页面导航过渡(MPA)

SPA 里做过渡还算简单,毕竟 DOM 在同一个文档中。但 MPA(多页应用)怎么办?页面 A 导航到页面 B,浏览器会销毁整个页面 A——传统方案完全无法做过渡。

Chrome 126+ 支持了跨文档 View Transitions(Cross-Document View Transitions),让 MPA 也能拥有丝滑的页面过渡。

1. 基础配置

/* 在两个页面的 CSS 中都添加 */
@view-transition {
  navigation: auto;
}

就这一行。当用户在两个页面之间导航(点击链接、前进/后退)时,浏览器自动触发 View Transition。

2. 自定义跨页面动画

/* 页面 A(列表页) */
.item-image {
  view-transition-name: item-image;
}

/* 页面 B(详情页) */
.detail-hero-image {
  view-transition-name: item-image;  /* 同名!这是关键 */
}

/* 自定义过渡效果 */
::view-transition-group(item-image) {
  animation-duration: 0.4s;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

/* 禁用 root 级别的淡入淡出,只保留图片过渡 */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

两个页面中设置相同的 view-transition-name,浏览器就能把页面 A 的元素和页面 B 的元素"匹配"起来,做跨页面的位移动画——这就是经典的"Hero Animation"。

3. 控制哪些导航触发过渡

// 使用 pageswap 和 pagereveal 事件精细控制
// 页面离开时
document.addEventListener('pageswap', (event) => {
  // 只对特定链接做过渡
  const targetUrl = new URL(event.activationEntry.url);
  if (targetUrl.pathname.startsWith('/blog/')) {
    // 设置匹配的 view-transition-name
    setTransitionNames(event);
  }
});

// 页面进入时
document.addEventListener('pagereveal', (event) => {
  if (event.viewTransition) {
    // 新页面准备好后,设置匹配名称
    setupIncomingNames(event);
  }
});
跨页面过渡的限制:两个页面必须同源;view-transition-name 必须在页面加载时就设置好(或通过事件动态设置);不支持跨域导航。

六、SPA 集成:React Router / Vue Router 实战

1. React Router

import { useNavigate } from 'react-router-dom';

function ProductCard({ product }) {
  const navigate = useNavigate();

  const handleClick = () => {
    // ✅ 用 startViewTransition 包裹导航
    const transition = document.startViewTransition(() => {
      navigate(`/product/${product.id}`);
    });
  };

  return (
    <div
      className="product-card"
      style={{ viewTransitionName: `product-${product.id}` }}
      onClick={handleClick}
    >
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
    </div>
  );
}
/* 详情页的 Hero 图片 */
.product-detail-hero {
  view-transition-name: product-hero; /* 需要与列表页匹配 */
}
React 18+ 的 concurrent 特性startViewTransition 的回调中更新状态时,React 18 的自动批处理可能导致 DOM 不立即更新。解决方案:使用 flushSync 强制同步更新。
import { flushSync } from 'react-dom';

document.startViewTransition(() => {
  flushSync(() => {
    navigate(`/product/${product.id}`);
  });
});

2. Vue Router

// router/index.js
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [...],
});

// ✅ 全局路由守卫中包裹 View Transition
router.beforeEach((to, from) => {
  // 检查浏览器支持
  if (!document.startViewTransition) return true;

  return new Promise((resolve) => {
    document.startViewTransition(async () => {
      resolve(true);
      await new Promise((r) => requestAnimationFrame(r));
    });
  });
});

更精细的控制——根据路由设置不同的过渡名称:

// 在组件中动态设置 view-transition-name
// ListPage.vue
<template>
  <div
    v-for="item in items"
    :key="item.id"
    :style="{ viewTransitionName: `item-${item.id}` }"
    @click="goToDetail(item.id)"
  >
    {{ item.name }}
  </div>
</template>

// DetailPage.vue
<template>
  <div :style="{ viewTransitionName: `item-${itemId}` }" class="detail-hero">
    <h1>{{ item.name }}</h1>
  </div>
</template>

3. 封装通用 Hook / Composable

// React: useViewTransition
function useViewTransition(callback) {
  return (...args) => {
    if (!document.startViewTransition) {
      callback(...args);
      return;
    }
    document.startViewTransition(() => callback(...args));
  };
}

// 使用
const handleNavigate = useViewTransition((id) => {
  flushSync(() => navigate(`/detail/${id}`));
});
// Vue: useViewTransition composable
import { nextTick } from 'vue';

export function useViewTransition() {
  const startTransition = async (callback) => {
    if (!document.startViewTransition) {
      await callback();
      return;
    }

    document.startViewTransition(async () => {
      await callback();
      await nextTick();
    });
  };

  return { startTransition };
}

七、列表动画

1. 列表重排过渡

经典的 FLIP 动画问题——列表排序时元素位置变化,需要平滑过渡。View Transitions API 让这一切变得极其简单:

function sortList(sortFn) {
  document.startViewTransition(() => {
    // 给每个列表项设置唯一的 view-transition-name
    document.querySelectorAll('.list-item').forEach((item) => {
      item.style.viewTransitionName = `item-${item.dataset.id}`;
    });

    // 执行排序,更新 DOM
    sortFn();
  });
}
/* 列表项重排时自动做位移动画 */
::view-transition-group(item-*) {
  animation-duration: 0.3s;
  animation-timing-function: ease-in-out;
}

/* 禁用默认的淡入淡出,只保留位移 */
::view-transition-old(item-*) {
  animation: none;
}
::view-transition-new(item-*) {
  animation: none;
}
为什么只保留 group 动画?::view-transition-group 默认会从旧的位置/尺寸动画到新的位置/尺寸——这正是 FLIP 动画要做的。禁用 old/new 的淡入淡出后,效果就是纯粹的"滑动到新位置",非常自然。

2. 动态网格过渡

function rearrangeGrid(newLayout) {
  document.startViewTransition(() => {
    // 每个网格项设置唯一 name
    document.querySelectorAll('.grid-item').forEach((item) => {
      item.style.viewTransitionName = `grid-${item.dataset.id}`;
    });

    // 应用新布局
    applyLayout(newLayout);
  });
}
/* 网格项过渡:位移 + 尺寸变化 */
::view-transition-group(grid-*) {
  animation-duration: 0.4s;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

/* 淡入淡出也保留,增加层次感 */
::view-transition-old(grid-*) {
  animation: 0.2s ease fade-out;
}
::view-transition-new(grid-*) {
  animation: 0.2s ease 0.2s fade-in;  /* 延迟淡入,错开节奏 */
}

3. 列表项添加/删除

function addItem(newItem) {
  document.startViewTransition(() => {
    // 现有项设置 name(保持位置过渡)
    document.querySelectorAll('.list-item').forEach((item) => {
      item.style.viewTransitionName = `item-${item.dataset.id}`;
    });

    // 新项不设 name(走 root 的淡入)
    addItemToDOM(newItem);
  });
}

function removeItem(id) {
  document.startViewTransition(() => {
    // 所有项都设 name
    document.querySelectorAll('.list-item').forEach((item) => {
      item.style.viewTransitionName = `item-${item.dataset.id}`;
    });

    // 删除目标项
    document.querySelector(`[data-id="${id}"]`)?.remove();
  });
}

八、性能考量与渐进增强

1. 性能原理

View Transitions 的性能关键在于:截图动画

  • 截图:浏览器将旧/新状态光栅化为位图,这个操作在合成线程执行,不阻塞主线程。
  • 动画:伪元素层的动画在 GPU 合成,和 CSS transform/opacity 动画一样高效。
  • DOM 更新updateCallback 中的 DOM 操作在主线程,这是唯一可能卡顿的环节。

2. 性能优化建议

/* ❌ 避免对大区域做复杂动画 */
::view-transition-group(root) {
  animation: 1s complex-animation;  /* 整个页面都在动画,GPU 压力大 */
}

/* ✅ 只对需要的元素做过渡 */
.special-element {
  view-transition-name: special;  /* 只这一个元素动画 */
}

::view-transition-group(root) {
  animation: none;  /* root 不做动画 */
}
优化点 建议
过渡时长 200-400ms 最佳,超过 500ms 用户会感觉拖沓
同时过渡元素数 建议 <10 个独立 name,过多会增加截图和合成开销
大尺寸元素 全屏图片过渡代价高,考虑降级处理
updateCallback 尽量同步完成,避免在回调中做大量异步操作
prefers-reduced-motion 始终尊重用户的无障碍偏好

3. 渐进增强

/* 尊重用户的动画偏好 */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root),
  ::view-transition-group(*) {
    animation-duration: 0.01ms !important;
  }
}

/* JS 中的渐进增强 */
function updateWithTransition(updateFn) {
  if (!document.startViewTransition) {
    // 不支持时直接更新,无动画
    updateFn();
    return;
  }

  document.startViewTransition(updateFn);
}

九、常见陷阱与兼容性

陷阱 1:view-transition-name 不唯一

/* ❌ 两个可见元素同名的后果:过渡被跳过 */
.card:first-child { view-transition-name: card; }
.card:last-child  { view-transition-name: card; }

/* ✅ 每个元素唯一 name */
.card { view-transition-name: var(--vt-name); }
// JS: card.style.setProperty('--vt-name', `card-${id}`);

陷阱 2:过渡期间 DOM 不可交互

过渡期间,伪元素层覆盖在真实 DOM 之上。用户点击按钮可能没反应。解决方案:

/* 短过渡通常无影响。如果确实需要交互: */
::view-transition-old(root),
::view-transition-new(root) {
  pointer-events: auto;  /* 允许点击穿透 */
}

/* 或者干脆缩短过渡时间 */
::view-transition-group(root) {
  animation-duration: 150ms;
}

陷阱 3:React 中 startViewTransition 回调不生效

// ❌ React 18 自动批处理,DOM 可能不会立即更新
document.startViewTransition(() => {
  setState(newValue);  // 不会立即渲染
});

// ✅ 用 flushSync 强制同步渲染
import { flushSync } from 'react-dom';
document.startViewTransition(() => {
  flushSync(() => {
    setState(newValue);  // 立即渲染,浏览器能截取新快照
  });
});

陷阱 4:跨页面过渡时 name 未提前设置

// ❌ 页面加载后才设置 name,浏览器已经截取了快照
window.addEventListener('load', () => {
  // 太晚了!
  document.querySelector('.hero').style.viewTransitionName = 'hero';
});

// ✅ 在 CSS 中静态设置,或在 pagereveal 事件中动态设置
// CSS 方式:
.hero { view-transition-name: hero; }

// JS 方式:
document.addEventListener('pagereveal', (e) => {
  if (e.viewTransition) {
    document.querySelector('.hero').style.viewTransitionName = 'hero';
  }
});

陷阱 5:忘记清理动态设置的 name

// ❌ 列表项切换后 name 残留
function showPage(page) {
  // 旧页面的 name 还在,可能和新页面冲突
  document.startViewTransition(() => {
    renderNewPage(page);
  });
}

// ✅ 过渡前清理旧 name,设置新 name
function showPage(page) {
  // 先清理
  document.querySelectorAll('[style*="view-transition-name"]')
    .forEach(el => el.style.viewTransitionName = 'none');

  document.startViewTransition(() => {
    renderNewPage(page);
    // 为新页面元素设置 name
    document.querySelectorAll('.card').forEach(card => {
      card.style.viewTransitionName = `card-${card.dataset.id}`;
    });
  });
}

兼容性速查(2026 年 6 月)

浏览器 同文档过渡 跨文档过渡 备注
Chrome 111+ ✅(126+) 完整支持
Edge 111+ ✅(126+) 同 Chrome
Firefox ✅(126+) 🚧 开发中 同文档过渡已支持
Safari 18+ 🚧 开发中 iOS 18+ 支持
Samsung Internet 跟随 Chrome

十、速查表

场景 代码 要点
最简过渡 document.startViewTransition(fn) 自动淡入淡出
修改过渡时长 ::view-transition-old/new(root) { animation-duration } 默认 250ms
元素独立过渡 view-transition-name: xxx 同一时刻必须唯一
禁用 root 过渡 ::view-transition-old/new(root) { animation: none } 只对特定元素过渡
跨页面过渡(MPA) @view-transition { navigation: auto } 两页面都需设置
Hero Animation 两页面设置相同 view-transition-name 自动匹配位置/尺寸
列表重排 每项设 view-transition-name,禁用 old/new 动画 只保留 group 位移
React 集成 flushSync + startViewTransition 强制同步渲染
Vue 集成 路由守卫 + nextTick() 等 DOM 更新完成
渐进增强 if (!document.startViewTransition) fallback() 不支持的浏览器直接更新
无障碍 @media (prefers-reduced-motion: reduce) 缩短或禁用动画
一句话总结:View Transitions API 用"浏览器帮你截图"的思路,彻底解决了 DOM 更新与动画割裂的痛点。startViewTransition 一行代码触发过渡,view-transition-name 实现元素级自定义,@view-transition { navigation: auto } 开启跨页面过渡。渐进增强,不支持时静默降级——没有不用的理由。