CSS View Transitions API:原生页面切换动画(不再装 Framer Motion)

起因

要做"列表页 → 详情页"的过渡动画——卡片放大变成详情头图。
传统做法:装 Framer Motion / GSAP 写 layoutId 切换,几十行 JS。

View Transitions API 是 2024 年 Chrome 全面支持的浏览器原生功能,
两行 CSS + 一行 JS 就能让任何 DOM 切换变成 morph 动画。

解决方案

1. 最简单的同页面状态切换

<style>
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.3s;
  }
</style>

<button id="toggle">切换</button>
<div id="content">Hello</div>

<script>
  document.getElementById('toggle').addEventListener('click', () => {
    if (!document.startViewTransition) {
      // fallback: 不支持的浏览器直接改
      document.getElementById('content').textContent =
        Math.random().toString(36).slice(2, 7)
      return
    }
    document.startViewTransition(() => {
      document.getElementById('content').textContent =
        Math.random().toString(36).slice(2, 7)
    })
  })
</script>

效果:内容用平滑 crossfade 切换,而不是瞬间替换。
默认动画时长 0.25s。

2. 给特定元素命名 → 跨页面 morph

<style>
  .product-card img {
    view-transition-name: product-hero;
  }
  .product-detail .hero {
    view-transition-name: product-hero;
  }
</style>

列表页的卡片图和详情页的头图都叫 product-hero
切换页面时(用 startViewTransition 包裹)浏览器自动 morph 一个元素到另一个。

// 列表页点击跳详情时
function navigate(href) {
  if (!document.startViewTransition) {
    location.href = href
    return
  }
  document.startViewTransition(() => {
    // 这里切换 DOM;SPA 用 router push
    router.push(href)
  })
}

效果:卡片图无缝放大到详情页大图位置,浏览器算 FLIP 动画。
不需要 Framer Motion 的 layoutId

3. SPA 路由集成

React Router 6.4+

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

function MyLink({ to, children }) {
  const navigate = useNavigate()
  return (
    <a
      href={to}
      onClick={(e) => {
        e.preventDefault()
        if (!document.startViewTransition) {
          navigate(to)
          return
        }
        document.startViewTransition(() => {
          flushSync(() => { navigate(to) })
          // flushSync 让 React 立刻渲染,view transition 才能比较新旧 DOM
        })
      }}
    >
      {children}
    </a>
  )
}

SvelteKit

+layout.svelte 加一行:

<script>
  import { beforeNavigate } from '$app/navigation'

  beforeNavigate(({ complete }) => {
    if (document.startViewTransition) {
      const transition = document.startViewTransition(() => complete)
    }
  })
</script>

Astro

直接 <ViewTransitions /> 标签开启全站,无需自己 hook:

---
import { ViewTransitions } from 'astro:transitions'
---
<head>
  <ViewTransitions />
</head>

4. 自定义动画

::view-transition-old(product-hero) {
  animation: 0.3s ease-out fade-out;
}
::view-transition-new(product-hero) {
  animation: 0.4s ease-in fade-in;
}

@keyframes fade-out {
  to { opacity: 0; transform: translateY(-20px); }
}
@keyframes fade-in {
  from { opacity: 0; transform: translateY(20px); }
}

每个命名元素独立设动画。无名的全局用 ::view-transition-old(root)

5. 同时多个 view-transition-name

详情页同时 morph 头图 + 标题 + 按钮:

.list .card .img { view-transition-name: hero-img; }
.list .card .title { view-transition-name: hero-title; }

.detail .hero { view-transition-name: hero-img; }
.detail h1 { view-transition-name: hero-title; }

浏览器同时为 hero-img 和 hero-title 做 morph,看起来三个元素
"飞到"详情页位置。

注意:同一时间 viewport 内每个 view-transition-name 只能有一个元素
列表页和详情页的元素不会同时存在,但列表页有两张卡片都叫 hero-img
就出错。要给每张卡片不同 name:

.card-1 .img { view-transition-name: hero-1; }
.card-2 .img { view-transition-name: hero-2; }

或者用 CSS variable:

.card .img {
  view-transition-name: var(--vt-name);
}
<div class="card" style={{ '--vt-name': `card-${id}` }}>

6. 检测支持

if ('startViewTransition' in document) {
  // 支持
} else {
  // 不支持,立刻切换(无动画)
}

Chrome / Edge 111+ 全支持;Safari 18+ 支持单文档;跨文档(MPA)navigation
还要 Chrome 126+。Firefox 还在开发。

不支持时优雅降级:动画不出现,但不影响功能。

实战 demo

<!DOCTYPE html>
<html>
<head>
<style>
  body { margin: 0; font-family: sans-serif; }
  .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; }
  .card { background: white; border-radius: 8px; overflow: hidden;
          cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
  .card img { width: 100%; height: 200px; object-fit: cover; }
  .detail { display: none; padding: 24px; }
  .detail.show { display: block; }
  .detail .hero { width: 100%; height: 400px; object-fit: cover; border-radius: 12px; }

  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.3s;
  }
</style>
</head>
<body>
<div class="grid" id="grid">
  <div class="card" data-id="1">
    <img src="https://picsum.photos/seed/1/800/600" style="view-transition-name: hero-1">
    <p>Card 1</p>
  </div>
  <div class="card" data-id="2">
    <img src="https://picsum.photos/seed/2/800/600" style="view-transition-name: hero-2">
    <p>Card 2</p>
  </div>
</div>

<div class="detail" id="detail">
  <img class="hero" id="hero-img">
  <button onclick="back()">← back</button>
</div>

<script>
  document.querySelectorAll('.card').forEach(card => {
    card.addEventListener('click', () => {
      const id = card.dataset.id
      const img = card.querySelector('img')
      // 详情页用同样的 view-transition-name
      const detailImg = document.getElementById('hero-img')
      detailImg.style.viewTransitionName = `hero-${id}`
      detailImg.src = img.src.replace('800/600', '1600/800')

      document.startViewTransition(() => {
        document.getElementById('grid').style.display = 'none'
        document.getElementById('detail').classList.add('show')
      })
    })
  })

  function back() {
    document.startViewTransition(() => {
      document.getElementById('detail').classList.remove('show')
      document.getElementById('grid').style.display = 'grid'
    })
  }
</script>
</body>
</html>

打开 Chrome 看,点 card 图片"飞"成大图 + 反向退出。零依赖。

效果

  • 列表 → 详情过渡从"瞬间切换"变"流畅 morph",体验提升明显
  • bundle 减去 ~30KB(不装 Framer Motion)
  • 与现有 framework 集成只需 5-10 行 hook 代码
  • 不支持的浏览器 graceful fallback 到无动画

跨文档 transitions(MPA)

Chrome 126+ 支持多页面 navigation 时的 view transition,
完全不需要 SPA。在两个 HTML 页面里 CSS 加:

@view-transition {
  navigation: auto;
}

浏览器 navigate 时自动应用 view transition。SSR / 静态站也能有
SPA 般的过渡

踩过的坑

  1. viewport 外的元素不参与:如果列表卡片在视口外,detail 元素
    morph 起点错误。让点击的卡片先 scrollIntoView 再触发 transition。

  2. 同名元素冲突:CSS 报"view-transition-name must be unique"。
    不要在 React map 里所有 item 用同 name。

  3. transition 期间 click 失效:transition 时整页冻结。复杂交互前
    注意 transition duration 别太长。

  4. iOS Safari 18 之前不支持:移动端覆盖度 2024 末期才到 80%+。
    渐进增强是必须的。

  5. 图片 src 切换 + view transition 并发:浏览器对新 image 还没
    下载完时 transition 显示空白。<img>decoding="sync"
    pre-cache 大图。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。