前言

之前在使用 b 站的时候总是觉得不好用,于是四处寻找脚本、扩展优化 b 站的浏览体验(优化摸鱼体验)。其中,有一个叫 BewlyBewly 的扩展比较大幅度的更改了 b 站的 ui 设计(不过目前这个扩展在 GitHub 上已不再维护),它的一个切换主题效果就是 ripple 水波扩散式的切换动画,虽然以前在 Element-Plus 官方文档页也看到过,但是没时间没想法只想躺平,所以没做。今天就来将它应用到博客上。

预览

切换方向 动画目标 裁剪方向 视觉效果
light→dark 上层 light 快照 扩展→收缩 light 样式从点击处向内收缩消失
dark→light 下层 light 快照 收缩→扩展 light 样式从点击处向外扩展显现

PixPin_2025-07-22_11-31-17

View Transition API

ripple 水波动画使用到的 API 为 View Transition API,再加上一点 clip-path 裁剪效果就可以做到。

兼容性

目前(截止至 2025-07-22),View Transition API 兼容性如下:

image-20250722112251679

动画原理

在介绍中可以看到,View Transition API 在更新 DOM 的同时,会创建不同 DOM 状态的快照:

image-20250722113519927

在不进行命名的情况下,我们能得到默认(root)的一组伪元素:

  1. ::view-transition-old(root)
  2. ::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)"  // 亮色:动画作用于新快照(暗色快照)
});

执行流程

  1. 快照层级::view-transition-old(root)(亮色)在 ::view-transition-new(root)(暗色)上方
  2. 动画目标:对上层的亮色快照应用 clip-path 动画
  3. 裁剪方向clipPath.reverse() → 从 circle(${radius}px...)circle(0%...)
  4. 视觉效果:亮色快照从鼠标点击位置开始收缩消失,逐渐露出下方的暗色快照
  5. 最终结果:亮色完全消失,暗色主题显现

DarkLight 依靠 isDark.reverse() 就能快捷的实现相反的动画效果。

注入 js

以主题 Butterfly 为例,只需要在_config.butterfly.ymlinject 区域进行注入即可。

我的 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;
}