📢 新文章推送 · 每周更新优质内容 · 订阅更新 →
向下滚动
开发编程

明暗主题切换动画开发记录

AI 智能总结

从简单到优雅的进化

最基础的主题切换实现是给 <body> 添加 dark class,然后通过 CSS 覆盖颜色。这种方式虽然简单,但切换瞬间颜色突变,视觉体验生硬。

v3.0 升级为圆形扩散动画:从主题切换按钮的位置向全屏展开一个圆形遮罩,展开过程中同步完成颜色切换,视觉上流畅自然。

基础方案:class 切换

最简单的实现:

 1const toggleBtn = document.querySelector('.theme-toggle');
 2toggleBtn.addEventListener('click', () => {
 3  document.body.classList.toggle('dark-theme');
 4  const isDark = document.body.classList.contains('dark-theme');
 5  localStorage.setItem('theme', isDark ? 'dark' : 'light');
 6});
 7
 8// 初始化
 9const saved = localStorage.getItem('theme');
10if (saved === 'dark') {
11  document.body.classList.add('dark-theme');
12}

CSS 变量控制颜色:

 1:root {
 2  --bg-color: #ffffff;
 3  --text-color: #333333;
 4}
 5.dark-theme {
 6  --bg-color: #1a1a2e;
 7  --text-color: #e0e0e0;
 8}
 9body {
10  background: var(--bg-color);
11  color: var(--text-color);
12  transition: background 0.3s, color 0.3s;
13}

高级方案:圆形扩散动画

原理

圆形扩散动画的核心思路:

  1. 创建一个覆盖全屏的圆形 clip-path 或遮罩层
  2. 从按钮位置(圆心)开始扩散,半径从 0 增长到能覆盖整个屏幕的对角线长度
  3. 扩散过程中,遮罩下方的颜色从旧主题过渡到新主题
  4. 扩散完成后移除遮罩层

实现步骤

步骤 1:创建扩散遮罩

 1function createRippleMask(startX, startY) {
 2  const mask = document.createElement('div');
 3  mask.classList.add('theme-ripple-mask');
 4  document.body.appendChild(mask);
 5
 6  const endRadius = Math.hypot(
 7    Math.max(startX, window.innerWidth - startX),
 8    Math.max(startY, window.innerHeight - startY)
 9  );
10
11  mask.style.setProperty('--start-x', startX + 'px');
12  mask.style.setProperty('--start-y', startY + 'px');
13  mask.style.setProperty('--end-radius', endRadius + 'px');
14
15  return mask;
16}

步骤 2:CSS clip-path 动画

 1.theme-ripple-mask {
 2  position: fixed;
 3  top: 0;
 4  left: 0;
 5  width: 100%;
 6  height: 100%;
 7  z-index: 9998;
 8  pointer-events: none;
 9  background: var(--next-theme-bg);
10  clip-path: circle(0 at var(--start-x) var(--start-y));
11  animation: rippleExpand 500ms ease-out forwards;
12}
13
14@keyframes rippleExpand {
15  0% {
16    clip-path: circle(0 at var(--start-x) var(--start-y));
17  }
18  100% {
19    clip-path: circle(var(--end-radius) at var(--start-x) var(--start-y));
20  }
21}

步骤 3:同步切换 class

动画进行到一半时,悄悄切换 <body> 的 class,然后移除遮罩层:

 1async function toggleTheme() {
 2  const btn = document.querySelector('.theme-toggle');
 3  const rect = btn.getBoundingClientRect();
 4  const startX = rect.left + rect.width / 2;
 5  const startY = rect.top + rect.height / 2;
 6
 7  const isDark = document.body.classList.contains('dark-theme');
 8  const mask = createRippleMask(startX, startY);
 9
10  // 等待动画过半
11  await new Promise(resolve => setTimeout(resolve, 250));
12
13  // 切换主题 class
14  document.body.classList.toggle('dark-theme');
15  localStorage.setItem('theme', isDark ? 'light' : 'dark');
16  document.documentElement.style.colorScheme = isDark ? 'light' : 'dark';
17
18  // 等待动画完成并清理
19  await new Promise(resolve => setTimeout(resolve, 300));
20  mask.remove();
21}

跟随系统

除了手动切换,还支持跟随系统主题:

 1const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
 2
 3function applySystemTheme(e) {
 4  if (localStorage.getItem('theme') === 'auto') {
 5    document.body.classList.toggle('dark-theme', e.matches);
 6  }
 7}
 8
 9mediaQuery.addEventListener('change', applySystemTheme);
10applySystemTheme(mediaQuery);

三种模式

模式行为
明亮(Light)始终使用浅色主题
暗黑(Dark)始终使用深色主题
跟随系统(Auto)监听系统主题变化,自动切换

要点总结

要点说明
CSS 变量使用 CSS 自定义属性管理颜色,而不是硬编码
动画时长500ms 是比较合适的扩散时长,太快太慢都不好
圆心位置从按钮位置扩散是最自然的过渡方式
局限性降级如果不支持 clip-path 动画,回退到普通 fade 过渡
持久化主题偏好保存到 localStorage,刷新后恢复
color-scheme切换 color-scheme meta 属性,让浏览器原生控件也跟着变
4 / 16
版权声明

本文作者 Lumin

本文链接 https://www.zhengquan.xyz/code/theme-switch-animation/

许可协议 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

请作者喝杯咖啡 ☕

  • 微信打赏
    微信支付
  • 支付宝打赏
    支付宝
点击按钮查看打赏二维码
🎁 推荐工具
试试这些实用在线工具,提升工作效率
前往工具集 →

留言评论

期待你的想法

评论加载中