View Transitions API
基本简介
View Transitions API 是一个浏览器原生功能,允许在页面状态变化时创建平滑过渡效果,无需复杂的 CSS 或 JavaScript 代码。它通过以下方式工作:
在更新 DOM 前捕获当前页面的快照 执行 DOM 更新 创建新旧视图之间的动画过渡
属性方法伪元素
startViewTransition()
Document.startViewTransition(callback): 开始一个新的视图过渡,并返回一个 ViewTransition 对象,callback 通常用于更新 DOM 的回调函数,它返回一个 Promise
这个回调函数在 API 截取了当前页面的屏幕截图后被调用。当回调函数返回的 Promise 兑现时,视图过渡将在下一帧开始。如果回调函数返回的 Promise 拒绝,过渡将被放弃。
ready
viewTransition.ready: 只读属性是一个 Promise。会在伪元素树被创建且过渡动画即将开始时兑(即执行注册的 then 回调)
如果视图过渡无法开始,ready 就会被拒绝。这可能是由于错误的配置,例如重复的 view-transition-name,或者是因为 Document.startViewTransition() 的回调函数抛出异常或返回的 Promise 被拒绝。
finished
viewTransition.finished: 只读属性是一个 Promise。会在过渡动画完成(新的页面视图对用户可见且可交互)时兑现
仅当传递给 document.startViewTransition() 的回调函数抛出异常或返回的 Promise 被拒绝时,finished 才会被拒绝,这表示页面的新状态未被创建。
如果过渡动画无法开始,或在动画期间使用 ViewTransition.skipTransition() 跳过了过渡动画,那么视图过渡依旧可以到达最终状态,因此 finished 依旧会被兑现。
updateCallbackDone
viewTransition.updateCallbackDone: 只读属性是一个 Promise。startViewTransition(callback) 回调函数返回的 Promise 兑现时,该 Promise 也会兑现,当回调函数返回的 Promise 被拒绝时,该 Promise 也会被拒绝
当你不关心过渡动画的成功或失败,而只关心 DOM 是否更新以及何时更新时,updateCallbackDone 非常有用
skipTransition()
viewTransition.skipTransition(): 跳过视图过渡的动画部分,但不跳过更新 DOM 的 startViewTransition(callback) 回调函数
伪元素
::view-transition - 根伪元素
::view-transition-old(root) - 旧视图的快照
::view-transition-new(root) - 新视图的快照
示例-主题切换动画

基础准备
1、基本结构
<div class="toolbar">
<el-switch
class="theme-switch"
:model-value="isDark"
@click="onToggleClick"
:active-icon="Moon"
:inactive-icon="Sunny"
active-text="深色"
inactive-text="浅色"
></el-switch>
</div>2、基本样式
.toolbar {
position: sticky;
top: 0;
z-index: 10;
padding: 12px 16px;
display: flex;
justify-content: flex-end;
align-items: center;
background-color: rgba(127, 127, 127, 0.05);
backdrop-filter: saturate(180%) blur(8px);
border-bottom: 1px solid var(--el-border-color);
}3、辅助函数
/**
* 切换亮暗主题
*/
function setupThemeClass(isDark) {
document.documentElement.classList.toggle("dark", isDark);
localStorage.setItem("theme", isDark ? "dark" : "light");
}
/**
* 计算过渡动画半径
* 创建从点击点向外扩散的效果,需要计算从点击点到屏幕上最远点的距离
*/
function computeMaxRadius(x, y) {
const maxX = Math.max(x, window.innerWidth - x);
const maxY = Math.max(y, window.innerHeight - y);
return Math.hypot(maxX, maxY);
}核心步骤
1、创建视图过渡动画
const transition = document.startViewTransition(async () => {
// 执行主题切换逻辑
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
});2、计算过渡动画半径
// event 点击事件
const x = event.clientX;
const y = event.clientY;
const endRadius = computeMaxRadius(x, y);3、创建圆形展开动画效果
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];
document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
fill: "both",
easing: "ease-in",
pseudoElement: isDark.value
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});4、设置伪元素的层叠顺序
/*
关闭默认的 CSS 动画并防止新旧视图状态以任何方式混合(新状态从旧状态上方“擦除”,而不是过渡
*/
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
html.dark::view-transition-old(root) {
z-index: 2147483646;
}
html.dark::view-transition-new(root) {
z-index: 1;
}完整示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>深色模式切换动画(修正版)</title>
<!-- Element Plus 样式与暗色变量 -->
<link
rel="stylesheet"
href="https://unpkg.com/element-plus/dist/index.css"
/>
<link
rel="stylesheet"
href="https://unpkg.com/element-plus/theme-chalk/dark/css-vars.css"
/>
<style>
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
transition: color 300ms ease, background-color 300ms ease;
font-family: var(--el-font-family), -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
}
.toolbar {
position: sticky;
top: 0;
z-index: 10;
padding: 12px 16px;
display: flex;
justify-content: flex-end;
align-items: center;
background-color: rgba(127, 127, 127, 0.05);
backdrop-filter: saturate(180%) blur(8px);
border-bottom: 1px solid var(--el-border-color);
}
.container {
max-width: 960px;
margin: 24px auto;
padding: 0 16px;
}
</style>
</head>
<body>
<div id="app">
<div class="toolbar">
<el-switch
class="theme-switch"
:model-value="isDark"
@click="onToggleClick"
:active-icon="Moon"
:inactive-icon="Sunny"
active-text="深色"
inactive-text="浅色"
></el-switch>
</div>
<div class="container">
<el-space direction="vertical" size="large" style="width: 100%">
<el-card shadow="hover">
<template #header>
<span>示例卡片</span>
</template>
<div>当前主题:{{ isDark ? '深色' : '浅色' }}</div>
<el-divider></el-divider>
<el-space wrap>
<el-button type="primary">主要按钮</el-button>
<el-button type="success">成功按钮</el-button>
<el-button type="warning">警告按钮</el-button>
<el-button type="danger">危险按钮</el-button>
<el-button>默认按钮</el-button>
</el-space>
</el-card>
<el-switch
class="theme-switch"
:model-value="isDark"
@click="onToggleClick"
:active-icon="Moon"
:inactive-icon="Sunny"
active-text="深色"
inactive-text="浅色"
></el-switch>
<el-alert
title="这是一条成功提示"
type="success"
show-icon
></el-alert>
<el-alert title="这是一条信息提示" type="info" show-icon></el-alert>
<el-alert
title="这是一条警告提示"
type="warning"
show-icon
></el-alert>
<el-alert title="这是一条错误提示" type="error" show-icon></el-alert>
</el-space>
</div>
</div>
<!-- Vue 3 & Element Plus CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.min.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script>
const { createApp, ref, onMounted, nextTick } = Vue;
function setupThemeClass(isDark) {
document.documentElement.classList.toggle("dark", isDark);
localStorage.setItem("theme", isDark ? "dark" : "light");
}
function computeMaxRadius(x, y) {
const maxX = Math.max(x, window.innerWidth - x);
const maxY = Math.max(y, window.innerHeight - y);
return Math.hypot(maxX, maxY);
}
const app = createApp({
setup() {
const isDark = ref(false);
const initTheme = () => {
const saved = localStorage.getItem("theme");
const preferDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
console.log(preferDark);
const next = saved ? saved === "dark" : preferDark;
isDark.value = next;
setupThemeClass(next);
};
function onToggleClick(event) {
const isAppearanceTransition =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!isAppearanceTransition || !event) {
isDark.value = !isDark.value;
return;
}
const x = event.clientX;
const y = event.clientY;
const endRadius = computeMaxRadius(x, y);
console.log(event);
const transition = document.startViewTransition(async () => {
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
// await nextTick()
});
console.log(endRadius, "🎈");
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];
document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
fill: "both",
easing: "ease-in",
pseudoElement: isDark.value
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
}
onMounted(initTheme);
const { Moon, Sunny } = ElementPlusIconsVue;
return { isDark, onToggleClick, Moon, Sunny };
},
});
// 全局注册 Element Plus 与图标(可选)
Object.entries(ElementPlusIconsVue).forEach(([key, component]) => {
app.component(key, component);
});
app.use(ElementPlus);
app.mount("#app");
</script>
</body>
</html>
<style>
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
html.dark::view-transition-old(root) {
z-index: 2147483646;
}
html.dark::view-transition-new(root) {
z-index: 1;
}
input::placeholder,
textarea::placeholder {
opacity: 1;
}
</style>