标题图片为B站的客户端深色模式切换的具体效果截图
原理分析
目前深色模式基本是通过prefers-color-scheme
这个系统预设属性来初始化的,如需具体切换时,可以通过JS给html
根节点增加class的方式来脱离系统预设(如:tailwindcss中的darkTheme: 'class'
这个配置)。
具体分析到这个客户端的华丽切换效果,如此细粒度的动画这应该不是单纯的css
或者js
能控制的。个人猜测应该是通过electron对切换前的当前APP屏幕截图进行保存,然后将旧页面图片和切换后的页面进行叠加,当切换为dark时,新页面(黑色)在上并且逐渐增大,当切换为light时,旧页面(黑色)在上,并且逐渐缩小。这种方法由于截图的存在,应该只能在electron环境中使用。
View Transition API 介绍
这个API主要用做对不同时间处于不同状态的DOM进行过渡处理,常用于SPA页面转场时的过渡效果,如下这类效果:
需要注意的是,这个API目前还处于试验阶段(预计edge 版本 111 发布),截止目前(edge 版本 110)浏览器还没有实装,但可以在edge dev channel中试验。具体可以查看Document.startViewTransition() - Web APIs | MDN (mozilla.org)。
代码的实现
CSS部分
View Transition API
产生过渡时,会生成一组伪元素,分别代表着状态变换前的图像和状态变换后的图像,并且默认就带有过滤渐隐渐现的过渡效果,我们主题切换不需要这种效果,将其默认效果关闭,同时也关闭过渡自带的mix-blend-mode
效果。
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* 进入dark模式和退出dark模式时,两个图像的位置顺序正好相反 */
.dark::view-transition-old(root) {
z-index: 1;
}
.dark::view-transition-new(root) {
z-index: 999;
}
::view-transition-old(root) {
z-index: 999;
}
::view-transition-new(root) {
z-index: 1;
}
JS部分
- 观察这个过渡动过可以发现,新产生的页面由点到圆直至最终覆盖完了整个页面。所以首先我们需要获取鼠标点击的位置,然后计算出过滤效果最终的位置圆的半径大小。
const toggleTheme = (event: MouseEvent) => {
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
}
- 使用
document.startViewTransition
,并传入更新DOM的方法,使浏览器记录两次状态时DOM的快照,即::view-transition-old(root)
和::view-transition-new(root)
:
const toggleTheme = (event: MouseEvent) => {
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
+ let isDark: boolean;
+ // @ts-ignore
+ const transition = document.startViewTransition(() => {
+ const root = document.documentElement;
+ isDark = root.classList.contains("dark");
+ root.classList.remove(isDark ? "dark" : "light");
+ root.classList.add(isDark ? "light" : "dark");
+ });
};
- 等待浏览器准备好了后,使用
element.animate
来驱动动画(当然也可以使用gsap
或者anime.js
等库或者自己去写缓动效果来实现)
const toggleTheme = (event: MouseEvent) => {
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
let isDark: boolean;
// @ts-ignore
const transition = document.startViewTransition(() => {
const root = document.documentElement;
isDark = root.classList.contains("dark");
root.classList.remove(isDark ? "dark" : "light");
root.classList.add(isDark ? "light" : "dark");
});
+ 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 ? clipPath.reverse() : clipPath,
+ },
+ {
+ duration: 200,
+ easing: "ease-in",
+ pseudoElement: isDark ? "::view-transition-old(root)" : "::view-transition-new(root)",
+ }
+ );
+ });
};
- 最后补充完DOM结构即可。
<div class="flex h-screen flex-col justify-between bg-white p-4 dark:bg-gray-800">
<div class="text-gray-800 dark:text-gray-100">
填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字
</div>
<div class="text-gray-800 dark:text-gray-100">
填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字
</div>
<div class="text-gray-800 dark:text-gray-100">
填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字填充文字
</div>
<div>
<button
class="rounded-md border p-2 outline-none dark:border-gray-900/50 dark:bg-gray-700 dark:text-gray-100"
onClick={toggleTheme}
>
切换主题
</button>
</div>
</div>
效果展示(注意edge/chrome版本至少为111,目前是最新版是110,建议使用dev版)
源码:yuhengshen/toggle-theme-demo (github.com)
源码新增分支 gsap: 使用gsap操作CSS变量来驱动动画效果(关键点:需要注意在CSS中定义好过渡时间,否则过渡效果瞬间就结束了)