面试官:手写一个深色模式切换过渡动画
在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切换动画"时,很多人可能只答出使用CSS transition,而忽略了现代浏览器的View Transitions API这一高级解决方案。
读完本文,你将掌握:
- 使用View Transitions API实现流畅的主题切换动画
- 理解深色模式切换的核心原理与实现细节
- 能够将这套方案应用到实际项目中,提升用户体验

前言
在实际项目中,深色模式切换几乎是前端的“标配”。常见做法是通过 classList.toggle("dark")
切换样式,再配合 transition
做淡入淡出。然而,这种效果在用户体验上略显生硬:颜色瞬间大面积切换,即便有渐变也会显得突兀。
随着 View Transitions API 的出现,我们可以给“页面状态切换”添加炫酷的过渡动画。今天就带大家实现一个 以点击位置为圆心、扩散切换主题的深色模式动画,读完本文你将收获:
- 了解
document.startViewTransition
的工作原理 - 学会用
clipPath
+animate
控制圆形扩散动画
核心铺垫:我们需要解决什么问题?
在设计方案前,先明确 3 个核心目标:
-
流畅过渡:避免普通
transition
的“整体闪烁”,实现局部扩散过渡。 - 交互感强:以用户点击位置为动画圆心,符合直觉。
- 可扩展:方案可适配 Vue3 组件体系,不依赖复杂第三方库。
为此,我们需要用到几个关键技术点:
-
View Transitions API:提供
document.startViewTransition
,可以对 DOM 状态切换设置过渡动画。 -
clip-path:通过
circle(r at x y)
定义动画圆形,从 0px 扩展到最大半径。 - computeMaxRadius:计算从点击点到四角的最大距离,确保圆形覆盖全屏。
-
.animate:使用
document.documentElement.animate
精确控制过渡过程。
Math.hypot:计算平面上点到原点的距离
Math.hypot()
是ES2017引入的一个JavaScript函数,用于计算所有参数平方和的平方根,即计算n维欧几里得空间中从原点到指定点的距离。

在深色模式切换动画中,我们使用它来计算覆盖整个屏幕的最大圆形半径:
斜边计算:
Math.hypot(maxX, maxY)
:使用勾股定理计算从点击点到对角的距离

clip-path

clip-path
是CSS属性,允许我们定义元素的可见区域,将其裁剪为基本形状或SVG路径。在深色模式切换动画中,我们用它创建从点击点向外扩散的圆形动画效果。
<basic-shape>
一种形状,其大小和位置由 <geometry-box>
的值定义。如果没有指定 <geometry-box>
,则将使用 border-box
用为参考框。取值可为以下值中的任意一个:
-
定义一个 inset 矩形。
-
定义一个圆形(使用一个半径和一个圆心位置)。
-
定义一个椭圆(使用两个半径和一个圆心位置)。
-
定义一个多边形(使用一个 SVG 填充规则和一组顶点)。
-
定义一个任意形状(使用一个可选的 SVG 填充规则和一个 SVG 路径定义)。
这里使用circle()
来实现效果
该函数接受以下参数:
- 半径:定义圆形的大小(0px到计算的最大半径)
- at关键词:分隔半径和中心点位置
- 中心点位置:使用x y坐标指定圆形中心
startViewTransition:浏览器视图转换API
基本概念
document.startViewTransition()
是View Transitions API的核心方法,它告诉浏览器DOM即将发生变化,并允许我们为这些变化创建平滑的过渡动画。
生命周期与关键事件
- 调用startViewTransition:浏览器准备开始视图转换
- 执行回调函数:DOM状态更新
- transition.ready事件:视图转换准备就绪,可以应用动画
- 视图转换完成:动画结束,新状态成为稳定状态
浏览器兼容性处理
在实际应用中,我们需要检查浏览器是否支持此API:
const isAppearanceTransition =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!isAppearanceTransition) {
// 不支持View Transitions API时的降级处理
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
return;
}
这种处理确保在不支持新特性的浏览器中,功能仍然可用,只是没有动画效果。
核心实现:从逻辑到代码
graph TD
A[用户点击切换按钮] --> B{浏览器是否支持<br/>View Transitions API?}
B -- 否 --> C[直接切换主题变量<br/>无动画效果]
B -- 是 --> D[获取点击坐标X,Y]
D --> E[计算覆盖全屏的最大半径]
E --> F[启动视图转换]
F --> G[执行回调函数<br/>更新isDark状态]
G --> H[设置HTML的dark class<br/>更新CSS变量]
H --> I[等待DOM更新完成<br/>nextTick]
I --> J[视图转换准备就绪]
J --> K[应用clipPath动画<br/>从点击点向外扩散]
K --> L[动画完成<br/>主题切换完成]
style B fill:#f9f,stroke:#333,stroke-width:2px
style K fill:#9cf,stroke:#333,stroke-width:2px
-
用户交互:用户点击切换按钮,触发主题切换流程
-
浏览器兼容性检查:判断当前浏览器是否支持View Transitions API
-
降级处理:在不支持API的浏览器中直接切换主题
-
动画核心逻辑:
- 获取点击位置作为动画起点
- 计算覆盖全屏的最大半径
- 启动视图转换过程
-
状态更新:实际执行主题状态更新和CSS类设置
-
动画触发:在视图转换准备就绪后,应用clipPath动画效果
-
完成:动画结束,新主题状态稳定
步骤 1:封装主题切换
function setupThemeClass(isDark) {
document.documentElement.classList.toggle("dark", isDark);
localStorage.setItem("theme", isDark ? "dark" : "light");
}
作用:控制 html.dark
类名,完成主题切换。
步骤 2:计算扩散最大半径
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); // √(maxX² + maxY²)
}
作用:确保无论点击哪里,扩散圆都能覆盖屏幕。
步骤 3:触发 View Transition
function onToggleClick(event) {
const isSupported =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!isSupported) {
// 回退方案:直接切换
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
return;
}
const x = event.clientX;
const y = event.clientY;
const endRadius = computeMaxRadius(x, y);
// 开启视图过渡
const transition = document.startViewTransition(async () => {
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
await nextTick(); // 等 Vue DOM 更新
});
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,
easing: "ease-in",
pseudoElement: isDark.value
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
}
要点:
*startViewTransition
接收一个回调函数,里面执行 DOM 更新(切换主题)。
*transition.ready.then(...)
可以在 DOM 更新后定义动画效果。
*clipPath
数组定义了从 小圆 → 大圆 的扩散过程。
*pseudoElement
控制是对 新视图 还是 旧视图 应用动画。
步骤 4:覆盖默认过渡样式
::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;
}
作用:取消默认动画,手动用 clipPath
控制。通过 z-index
确保层级正确,否则可能看到“旧页面覆盖新页面”的异常。
效果演示

运行后:
- 点击切换按钮时,以点击点为圆心,圆形扩散覆盖全屏,主题在扩散动画过程中完成切换。
- 若浏览器不支持
View Transitions API
(如 Safari),则自动降级为普通切换,不影响使用。
完整demo
延伸与避坑
-
兼容性问题
- View Transitions API 目前在 Chromium 内核浏览器(Chrome 111+、Edge)可用,Safari/Firefox 尚未支持。
- 可加上
isSupported
判断,优雅降级。
-
性能优化
- 动画时建议避免页面过多重绘(如大量图片加载),否则会掉帧。
- clip-path 本身是 GPU 加速属性,性能较好。
-
扩展思路
- 除了圆形扩散,还可以用
polygon()
实现“百叶窗切换”或“对角线切换”。 - 可以结合 路由切换 做“页面级过渡动画”。
- 除了圆形扩散,还可以用
总结
本文我们用 Vue3 + Element Plus + View Transitions API 实现了一个点击扩散式的深色模式切换动画,核心要点:
- startViewTransition:声明 DOM 状态切换的动画上下文。
- clipPath + animate:控制过渡动画形状与过程。
- computeMaxRadius:计算圆形覆盖全屏的半径。
- 优雅降级:确保不支持 API 的浏览器仍能正常切换。