从简单到优雅的进化
最基础的主题切换实现是给 <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}
高级方案:圆形扩散动画
原理
圆形扩散动画的核心思路:
- 创建一个覆盖全屏的圆形 clip-path 或遮罩层
- 从按钮位置(圆心)开始扩散,半径从 0 增长到能覆盖整个屏幕的对角线长度
- 扩散过程中,遮罩下方的颜色从旧主题过渡到新主题
- 扩散完成后移除遮罩层
实现步骤
步骤 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 属性,让浏览器原生控件也跟着变 |
留言评论
期待你的想法评论加载中