用自绘遮罩实现丝滑的 Dark / Light 主题切换

用自绘遮罩实现丝滑的 Dark / Light 主题切换 封面图

最近给博客做主题切换时,我想要一个更有“开关感”的动画:点击右上角的 light / dark 图标后,新主题从按钮位置像圆一样扩散出来;反向切换时,旧主题再从整屏收回按钮位置。

这个效果最常见的实现方式是 View Transition API。它的思路很直接:浏览器帮我们截取旧页面和新页面的快照,然后我们只需要控制 ::view-transition-old(root)::view-transition-new(root) 的裁剪路径,就能做出圆形扩散。

但在真实项目里,我最后没有直接使用原生 View Transition,而是改成了自绘遮罩层。原因也很现实:dark 切到 light 时,旧的深色截图层会被浏览器短暂放到最上面,在某些合成时机下会出现整屏黑色闪一下。动画本身是对的,但视觉上会有明显抖动。

基础思路:从点击位置计算一个足够大的圆

无论使用 View Transition 还是自绘遮罩,核心都一样:找到动画圆心,再计算一个足够覆盖整个视口的半径。

圆心通常取自点击事件。如果不是鼠标触发,比如键盘触发,就回退到切换按钮中心点。

const getThemeTransitionPoint = (event, button) => {
  const rect = button?.getBoundingClientRect();
  const fallbackPoint = rect
    ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
    : { x: window.innerWidth / 2, y: window.innerHeight / 2 };

  if (event && (event.clientX || event.clientY)) {
    return { x: event.clientX, y: event.clientY };
  }

  return fallbackPoint;
};

半径用 Math.hypot() 计算。它相当于勾股定理,用触发点到四个窗口边缘的最大距离算出一个覆盖全屏的圆。

const getClipPath = ({ x, y }) => {
  const radius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y)
  );

  return {
    closed: 'circle(0px at ' + x + 'px ' + y + 'px)',
    open: 'circle(' + radius + 'px at ' + x + 'px ' + y + 'px)',
  };
};

这两个值就是动画的起点和终点:

  • closed:半径为 0,只露出按钮附近的一个点。
  • open:半径足够大,能覆盖整个页面。

View Transition API 的版本

参考文章里的方案大致是这样:

const transition = document.startViewTransition(() => {
  setTheme(nextTheme);
});

await transition.ready;

await document.documentElement.animate(
  {
    clipPath: isToLight
      ? [clipPath.open, clipPath.closed]
      : [clipPath.closed, clipPath.open],
  },
  {
    duration: 520,
    easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
    pseudoElement: isToLight
      ? '::view-transition-old(root)'
      : '::view-transition-new(root)',
  }
).finished;

这个版本的优点是代码短,浏览器会自动帮我们处理新旧页面快照。

问题出在 dark → light。这个方向通常需要让旧的暗色快照收缩,所以动画对象会落在 ::view-transition-old(root) 上。旧快照是整屏深色页面,它在动画开始时位于最上层。如果浏览器先绘制了这一层,再执行裁剪动画,就会出现一帧整屏黑色。

这就是“黑色闪烁”的来源。

最终方案:自己画一个过渡层

为了避开浏览器的截图层,我改成手动创建一个遮罩层:

  1. 创建一个固定定位的 .theme-reveal-overlay
  2. 克隆当前页面 DOM,放到遮罩层里。
  3. 给遮罩层套上目标主题或当前主题的 CSS 变量。
  4. clip-path: circle(…) 做展开或收起。
  5. 动画结束后移除遮罩层。

两个方向的处理不一样。

light → dark 时,新暗色页面应该从按钮处展开。因此遮罩层使用 dark 主题,从 closed 动到 open。等遮罩铺满屏幕后,再把真实页面切到 dark。

dark → light 时,旧暗色页面应该收起来。因此先创建一个 dark 的旧页面克隆层铺满全屏,然后真实页面立刻切到 light,最后让克隆层从 open 动到 closed

这样用户看到的是旧暗色页面自然收缩,而不是浏览器插入一张整屏黑色截图。

关键实现

下面是核心逻辑的简化版:

const switchTheme = async (event) => {
  if (isThemeTransitioning) return;

  const currentTheme = getCurrentTheme();
  const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';

  if (!Element.prototype.animate || prefersReducedMotion.matches) {
    setTheme(nextTheme);
    return;
  }

  const point = getThemeTransitionPoint(event, themeButton);
  const clipPath = getClipPath(point);
  const isToLight = nextTheme === 'light';

  isThemeTransitioning = true;
  document.documentElement.classList.add('theme-reveal-is-running');

  const overlay = createThemeOverlay(
    isToLight ? currentTheme : nextTheme,
    isToLight ? clipPath.open : clipPath.closed
  );

  try {
    if (isToLight) {
      setTheme(nextTheme);
      await animateOverlay(overlay, clipPath.open, clipPath.closed);
    } else {
      await animateOverlay(overlay, clipPath.closed, clipPath.open);
      setTheme(nextTheme);
    }
  } finally {
    overlay.remove();
    requestAnimationFrame(() => {
      document.documentElement.classList.remove('theme-reveal-is-running');
    });
    isThemeTransitioning = false;
  }
};

创建遮罩层时,不要只画一块纯色背景。纯色背景虽然简单,但 dark → light 时会像黑色幕布一样遮住内容,看起来仍然像闪黑。更稳的做法是克隆页面内容:

const createThemeOverlay = (theme, initialClipPath) => {
  const overlay = document.createElement('div');
  const content = document.createElement('div');

  overlay.className = 'theme-reveal-overlay theme-reveal-overlay--' + theme;
  overlay.setAttribute('aria-hidden', 'true');
  overlay.style.clipPath = initialClipPath;
  overlay.style.setProperty('-webkit-clip-path', initialClipPath);

  content.className = 'theme-reveal-overlay__content';
  content.style.minHeight = document.documentElement.scrollHeight + 'px';
  content.style.marginTop = '-' + window.scrollY + 'px';

  Array.from(document.body.children).forEach((child) => {
    if (child.tagName === 'SCRIPT' || child.classList?.contains('theme-reveal-overlay')) {
      return;
    }

    content.appendChild(child.cloneNode(true));
  });

  overlay.appendChild(content);
  document.body.appendChild(overlay);

  return overlay;
};

动画本身只需要控制 clip-path

const animateOverlay = (overlay, fromClipPath, toClipPath) => overlay.animate(
  [
    { clipPath: fromClipPath, WebkitClipPath: fromClipPath },
    { clipPath: toClipPath, WebkitClipPath: toClipPath },
  ],
  {
    duration: 520,
    easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
    fill: 'both',
  }
).finished;

CSS:让遮罩层自己拥有主题变量

一开始我尝试过临时切换真实页面的主题,再读取 CSS 变量给遮罩层。这个办法能工作,但不够干净。更好的方式是让遮罩层自己拥有 light / dark 两套变量。

:root,
.theme-reveal-overlay--light {
  color-scheme: light;
  --bg: #ffffff;
  --text: #171717;
  --line: #d4d4d4;
}

:root.dark,
html.dark,
.theme-reveal-overlay--dark {
  color-scheme: dark;
  --bg: #171717;
  --text: #f5f5f5;
  --line: #525252;
}

遮罩层外壳保持透明,真正的背景交给克隆内容去画。这样即使浏览器某一帧还没把克隆内容合成出来,也不会先闪出一整块黑底。

html.theme-reveal-is-running *,
html.theme-reveal-is-running *::before,
html.theme-reveal-is-running *::after {
  transition: none !important;
}

.theme-reveal-overlay {
  pointer-events: none;
  position: fixed;
  inset: 0;
  z-index: 2147483647;
  overflow: hidden;
  background: transparent;
  color: var(--text);
  will-change: clip-path;
}

.theme-reveal-overlay__content {
  position: relative;
  display: flex;
  width: 100%;
  min-height: 100svh;
  flex-direction: column;
  background: var(--bg);
  color: var(--text);
}

theme-reveal-is-running 这层类用来临时关闭普通 CSS transition。否则主题变量切换时,背景色、边框色、文字色也会各自动画,容易和圆形遮罩叠在一起,产生轻微抖动。

为什么这个方案更稳

View Transition API 的优势是简单,但它的动画对象是浏览器生成的截图伪元素。我们能控制它的 clip-path,却很难控制截图层什么时候完成合成、旧层和新层谁先被画出来。

自绘遮罩方案虽然多写了一点代码,但每一步都可控:

  • 遮罩层什么时候创建由我们决定。
  • light → dark 和 dark → light 的主题切换时机可以分开处理。
  • 遮罩背景可以保持透明,避免空黑底。
  • 页面 DOM 克隆后不会执行脚本,适合做短暂的视觉快照。
  • 动画结束后移除节点,不影响正常页面结构。

最终的视觉结果是:深色模式展开时像墨水扩散,浅色模式返回时像旧暗色页面被收起。动画方向完整,且不会再有整屏黑色闪烁。

实现时的检查清单

做这类效果时,我会重点检查这几件事:

  • 主题切换期间是否阻止了重复点击。
  • prefers-reduced-motion 是否能直接跳过动画。
  • 圆形半径是否能覆盖整个视口。
  • 滚动位置不在顶部时,克隆内容是否和当前页面对齐。
  • 遮罩层是否在动画结束后移除。
  • dark → light 是否没有整屏黑色空白帧。
  • SVG 图标、卡片、图片等深色样式在遮罩层里是否也生效。

如果只是做 demo,View Transition API 已经足够漂亮;如果要放到自己的博客主题里长期使用,我更倾向于这个自绘遮罩版本。它少依赖浏览器截图层,多一点代码,换来的是更稳定的视觉结果。