js+css 实现 Hexo 博客 ripple 水波切换主题效果
前言
之前在使用 b 站的时候总是觉得不好用,于是四处寻找脚本、扩展优化 b 站的浏览体验(优化摸鱼体验)。其中,有一个叫 BewlyBewly
的扩展比较大幅度的更改了 b 站的 ui 设计(不过目前这个扩展在 GitHub 上已不再维护),它的一个切换主题效果就是 ripple 水波扩散式的切换动画,虽然以前在 Element-Plus 官方文档页也看到过,但是没时间没想法只想躺平,所以没做。今天就来将它应用到博客上。
预览
切换方向 | 动画目标 | 裁剪方向 | 视觉效果 |
---|---|---|---|
light→dark | 上层 light 快照 | 扩展→收缩 | light 样式从点击处向内收缩消失 |
dark→light | 下层 light 快照 | 收缩→扩展 | light 样式从点击处向外扩展显现 |
View Transition API
ripple 水波动画使用到的 API 为 View Transition API,再加上一点 clip-path 裁剪效果就可以做到。
兼容性
目前(截止至 2025-07-22),View Transition API
兼容性如下:
动画原理
在介绍中可以看到,View Transition API 在更新 DOM 的同时,会创建不同 DOM 状态的快照:
在不进行命名的情况下,我们能得到默认(root)的一组伪元素:
::view-transition-old(root)
::view-transition-new(root)
注意该组快照的层级关系,旧的未发生 DOM 改变的状态快照在新的快照上方。
现在,我们有了两张前后快照,再想到 clip-path 裁剪效果,动画原理已经很明朗了。
Light 转 Dark 示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 当切换到暗色主题时,isDark = true
const isDark = newTheme === "dark";
// 得到鼠标点击位置clientX和clientY计算具体效果呈现
const clipPath = [
`circle(0% at ${clientX}px ${clientY}px)`, // 起始:圆形裁剪区域为0
`circle(${radius}px at ${clientX}px ${clientY}px)` // 结束:圆形扩展到全屏
];
document.documentElement.animate({
clipPath: isDark ? clipPath.reverse() : clipPath, // 暗色时反向
}, {
pseudoElement: isDark
? "::view-transition-old(root)" // 暗色:动画作用于旧快照(亮色快照)
: "::view-transition-new(root)" // 亮色:动画作用于新快照(暗色快照)
});
执行流程:
- 快照层级:
::view-transition-old(root)
(亮色)在::view-transition-new(root)
(暗色)上方 - 动画目标:对上层的亮色快照应用 clip-path 动画
- 裁剪方向:
clipPath.reverse()
→ 从circle(${radius}px...)
到circle(0%...)
- 视觉效果:亮色快照从鼠标点击位置开始收缩消失,逐渐露出下方的暗色快照
- 最终结果:亮色完全消失,暗色主题显现
Dark
转 Light
依靠 isDark
和.reverse()
就能快捷的实现相反的动画效果。
注入 js
以主题 Butterfly 为例,只需要在_config.butterfly.yml
的 inject
区域进行注入即可。
我的 ripple-toggle.js
代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/**
* Author: 4rozeN
* Date:2025-07-21 10:52:40
* LastEditTime: 2025-07-21 20:25:39
* LastEditors: 4rozeN
* Description:优化切换主题动画。
*/
const getCurrentTheme = () =>
document.documentElement.getAttribute("data-theme") || "light";
const setTheme = (theme) => {
document.documentElement.setAttribute("data-theme", theme);
const expiry = Date.now() + 1000 * 60 * 60 * 24;
localStorage.setItem("theme", JSON.stringify({ value: theme, expiry }));
};
const isMobile = () =>
/Android|iPhone|iPad|iPod|Windows Phone|Mobile|iOS/i.test(
navigator.userAgent
);
const rippleToggle = (e, point) => {
const currentTheme = getCurrentTheme();
const newTheme = currentTheme === "dark" ? "light" : "dark";
// 如果是移动设备,直接切换,不用动画
if (isMobile()) {
console.log("mobile设备跳过动画");
return;
}
// 检查浏览器支持
if (!document.startViewTransition) {
console.log("浏览器限制,直接切换主题");
if (e?.stopImmediatePropagation) {
e.stopImmediatePropagation(); // 阻止同一元素上其他监听器执行
}
setTheme(newTheme);
return;
}
if (e?.stopImmediatePropagation) {
e.stopImmediatePropagation(); // 阻止同一元素上其他监听器执行
}
const transition = document.startViewTransition(() => {
requestAnimationFrame(() => {
setTheme(newTheme);
});
});
transition.ready.then(() => {
const { clientX, clientY } = point || e;
// console.log(`clientX: ${clientX} | clientY: ${clientY}`, e, point);
const radius = Math.hypot(
Math.max(clientX, innerWidth - clientX),
Math.max(clientY, innerHeight - clientY)
);
const clipPath = [
`circle(0% at ${clientX}px ${clientY}px)`,
`circle(${radius}px at ${clientX}px ${clientY}px)`,
];
const isDark = newTheme === "dark";
document.documentElement.animate(
{
clipPath: isDark ? clipPath.reverse() : clipPath,
},
{
duration: 300,
easing: "ease-out",
pseudoElement: isDark
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
};
const bindRippleThemeToggle = () => {
const btn = document.getElementById("darkmode");
if (!btn) {
console.log("找不到对应的切换按钮元素");
return;
}
btn.removeEventListener("click", rippleToggle);
btn.addEventListener("click", rippleToggle);
};
["DOMContentLoaded", "pjax:complete"].forEach((evt) =>
window.addEventListener(evt, bindRippleThemeToggle)
);
注入 css
注入步骤同上,我的 ripple-toggle.css
代码如下:
1
2
3
4
5
6
7
8
9
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
html[data-theme="dark"]::view-transition-old(root) {
z-index: 100;
}
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 林之介 | 4rozeN!
评论