HexoHexoAPIjs+css 实现 Hexo 博客 ripple 水波切换主题效果
4rozeN
前言
之前在使用 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
| const isDark = newTheme === "dark";
const clipPath = [ `circle(0% at ${clientX}px ${clientY}px)`, `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
|
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;
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 10
| ::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; }
|