ThemeTransitionToggle:把深浅模式切换设计成一次几何揭幕
深浅模式切换很容易被写成一个很轻的功能:点一下按钮,改一下 html.dark,再让 CSS Variables 接管颜色。
这当然能工作,但它少了一层体验上的交接。用户看到的不是“一个新的视觉状态被打开”,而是“页面突然换色”。如果项目本身对视觉节奏、主题质感、暗色层次比较敏感,这个瞬间会显得有点粗糙。
ThemeTransitionToggle 想解决的不是“如何切换主题”这个问题,而是另一个更具体的问题:
当整个页面的视觉语义发生变化时,能不能让这次变化从用户的操作点自然展开,并且不牺牲真实 DOM、可访问性和组件复用?
最后的方案是:用浏览器 View Transition API 承接旧主题和新主题的截图层,再用 clip-path 做圆形、椭圆、五角星、菱形、六边形等几何揭幕。按钮本身仍然是一个熟悉的双状态切换控件,图标保留轻微旋转反馈;全屏主题过渡则被封装为一个可复用能力。
封装后的组件已经沉淀到 QiuYe UI:可以在 ThemeTransitionToggle 组件详情页 里查看 API、示例并直接体验切换效果。
一、设计目标:不是炫技,而是把切换变得可控
主题切换动画最怕两件事。
第一是“看起来很酷,但实现很脆”。比如手写一个全屏 overlay,把颜色盖过去,再在某个时刻切主题。这类方案很容易遇到尾帧闪烁、滚动区域露底、真实 DOM 和视觉状态不同步的问题。
第二是“组件很方便,但能力被按钮绑死”。如果主题过渡只能通过某一个按钮触发,那它就很难用在菜单项、快捷键、设置面板、命令面板等场景里。
所以这个组件从一开始就拆成了三层目标:
| 目标 | 具体含义 |
|---|---|
| 真实切换 | 主题仍然通过项目原本的 setTheme、class、CSS Variables 生效 |
| 视觉连续 | 旧画面和新画面之间通过截图层交接,而不是直接闪断 |
| 能力复用 | 底层函数、Hook、按钮组件分别暴露,避免把动画能力锁死在某个 UI 上 |
最终的结构大概是这样:
这个拆法有一个很重要的前提:动画不是主题系统本身。动画只是一次视觉交接;主题状态仍然要由应用自己的主题系统负责。
二、View Transition 的关键认知:你动画的不是 DOM,而是截图
View Transition API 的好处,是它能把一次 DOM 更新包进浏览器管理的过渡流程里。
简化来看,它会做三件事:
- 记录更新前的页面截图,也就是
::view-transition-old(root) - 执行你传入的 DOM 更新回调
- 记录更新后的页面截图,也就是
::view-transition-new(root)
然后你可以对这些伪元素做动画。
主题切换非常适合这个模型,因为主题变化往往影响整个页面:背景色、文字色、边框、阴影、代码块、卡片、图标都可能一起变化。与其试图给每个真实 DOM 元素分别加 transition,不如让浏览器捕获两个稳定画面,再对截图层做一次统一的几何揭幕。
核心代码很短:
这里最容易忽略的是:startViewTransition 的 callback 里一定要完成真实主题状态的提交。不要先播放动画,再找个时间点切主题。那样视觉层和真实 DOM 会错位,也很容易在动画结束时补一帧旧主题。
三、先关掉默认 crossfade,再建立自己的层级
浏览器默认的 View Transition 会带 crossfade。对普通页面跳转来说,这很合理;但对主题揭幕来说,它会和几何遮罩互相干扰。
我们想要的是非常明确的画面关系:
- 某一层在上面,被
clip-path打开或收回 - 另一层在下面,作为稳定的目标画面
- 不要额外混合、淡入、默认动画
因此组件会在过渡期间临时注入一段样式:
实际实现里,z-index 会根据当前动画层动态调整。因为有些方向需要动 new 层,有些方向需要动 old 层。
这是一类很典型的 View Transition 实践:先把浏览器默认效果清干净,再只保留自己需要的那一个动画属性。
四、方向不是对称的:浅入深扩散,深入浅收束
深浅模式切换看起来是同一个按钮的两次状态翻转,但视觉上并不一定应该完全对称。
在这个组件里,默认策略是:
- 浅色切到深色:扩散
::view-transition-new(root),让深色主题从触发点向外打开 - 深色切到浅色:收束
::view-transition-old(root),让深色旧画面缩回触发点,露出下方浅色主题
这也是为什么 API 里有 direction:
auto 不是为了偷懒,而是为了把最常见的主题切换语义固定下来。进入深色时,暗色像一层夜幕展开;回到浅色时,暗色退场,光从下面显出来。
如果某个产品想表达另一种关系,比如永远让新主题从按钮处扩散,也可以把 direction 显式设为 "enter"。
五、覆盖半径要算出来,不要靠感觉猜
几何揭幕最常见的问题是边角漏色。尤其是触发点不在屏幕中心时,100vw、150vmax、200vw 这种经验值都不够可靠。
圆形覆盖的正确模型很简单:从动画原点到视口四个角,取最大距离。
这里还有一个小细节:视口尺寸不能只看 window.innerWidth。移动端地址栏、缩放、某些桌面浏览器布局场景下,documentElement.clientWidth 和 visualViewport 也可能提供有用信息。因此实现里会取一个更稳的最大值:
椭圆会更容易出错。因为 ellipse(rx ry at x y) 的 rx 和 ry 不相等,如果只是把圆形半径乘一个比例,角落很容易漏掉。
这里的做法是先把 y 方向按比例折算回 x 方向,再求最大覆盖半径:
对于五角星、菱形、六边形这类 polygon() 图形,覆盖判断更复杂。它们不是各向同性的圆,尖角、凹角、旋转角度都会影响边缘覆盖。
工程上可以先采用“圆形最远角半径 + 图形修正倍率”的方式,保证可控和容易调试:
这不是最数学洁癖的写法,但它很好维护:每一种图形的视觉扩张能力不同,用具名倍率表达这件事,比把一段难读的几何推导塞进组件更适合组件库。
六、尾帧防闪:主题要同步,清理要晚一拍
这个组件最值得记录的实践,反而不是动画本身,而是防闪。
主题切换经常会接入 next-themes 或类似方案。它们可能通过 effect、storage、系统主题监听等方式在稍后的时机修正 html class。如果 View Transition 的截图已经完成,而真实 DOM 的 class 在动画尾部又被补切一次,用户就会看到一帧很刺眼的旧主题闪回。
解决思路有两步。
第一,在 View Transition 的 update 阶段同步写入 html.dark,再调用项目自己的更新函数:
第二,用 flushSync 确保 React 状态更新在截图捕获阶段完成:
这可以把真实 DOM、React 状态、View Transition 的 new snapshot 尽量对齐到同一个阶段。
但仅仅这样还不够。动画结束后如果立刻 cancel() 动画并移除临时样式,浏览器可能还没把最后一帧稳定绘制出来。于是实现里会等两次 requestAnimationFrame 再清理:
最终清理流程是:
这就是很多动画组件里容易漏掉的一拍:完成不等于已经稳定呈现。先让浏览器把完成态画出来,再移除辅助层。
七、时间曲线:全屏过渡不要盲目追求“弹”
最开始我也很自然地想把它做成更明显的 spring 手感。毕竟按钮图标旋转有弹性,主题揭幕是不是也应该弹一下?
实际体验并不好。
全屏 clip-path 和一个小图标的运动感知完全不同。小图标的轻微回弹会显得灵动;但全屏遮罩如果尾段突然慢下来,或者半径出现过强的“弹性停靠”,用户会明显感觉页面在拖沓。
最终默认值收敛成:
这个曲线的特点是启动足够干脆,中后段不会突然肉下来。它不像强 spring 那样有明显 overshoot,也不会像普通 ease-out 一样把尾段拉得过长。
这里有一个实践经验:全屏状态切换的动效要比微交互更克制。
按钮可以有旋转、缩放、图标切换;整页主题切换最好保持一个稳定、直接、可预期的节奏。
因此 ThemeTransitionToggle 把两类时间拆开:
| 层级 | 默认值 | 负责的体验 |
|---|---|---|
| 全屏主题揭幕 | duration: 580、easing: cubic-bezier(0.17,0.84,0.44,1) | 页面状态交接 |
| 按钮微交互 | effect: "rotate"、transitionDuration: 0.35 | 触发反馈和图标状态变化 |
这两个动画应该互相呼应,但不应该绑成同一个参数。
八、触发器:按钮是双状态控件,不是一次性播放按钮
主题按钮不是普通 action button。它表达的是一个持续状态:当前是浅色还是深色,下一次点击会去哪里。
所以 ThemeTransitionToggle 复用了组件库里的 DualStateToggle:
这里的关键不是“用了哪个按钮组件”,而是几个交互边界:
- 图标状态由
isDark决定,而不是动画内部自己猜 - 过渡期间按钮禁用,避免多次并发切换
aria-pressed表达当前状态aria-label表达下一次操作- 按钮形状
buttonShape和揭幕形状shape分开配置
最后一点很重要。按钮可以是方形、圆形,也可以和 Header 里的其他 icon button 保持统一;但全屏揭幕可以是圆、椭圆、五角星。这两者属于不同层级的设计语言,不应该被一个 shape 属性混在一起。
九、API 分层:函数、Hook、组件各做一件事
为了让能力不被按钮锁死,组件暴露了三层 API。
第一层是底层函数:
它适合命令面板、菜单项、快捷键、自定义坐标触发等场景。
第二层是 Hook:
Hook 适合你已经有自己的按钮样式,只想接入过渡能力的场景。
第三层才是开箱即用组件:
这个分层让组件库能力更耐用:今天它服务 Header 主题按钮,明天也可以服务设置抽屉、命令面板、作品展示页的视觉切换。
十、兼容与降级:好动画不应该成为必需品
View Transition API 仍然不是所有环境都完整支持。再加上用户可能开启 prefers-reduced-motion,所以这类全屏动画必须有降级路径。
runThemeViewTransition 的判断很直接:
这里的原则是:降级时不要伪装成另一个复杂动画。直接完成真实主题切换,保持状态正确,比在不稳定环境里模拟一套半残缺效果更可靠。
动画是体验增强,不是主题功能的依赖。
十一、这次组件沉淀下来的几个实践
写完 ThemeTransitionToggle 后,我觉得这类“全局视觉状态切换”可以沉淀成一张检查表。
| 检查项 | 原因 |
|---|---|
DOM 更新是否发生在 startViewTransition callback 内 | 保证 old / new snapshot 捕获正确 |
| 是否关闭默认 crossfade | 避免浏览器默认动画和自定义遮罩叠加 |
动画层是 old 还是 new | 决定 reveal / exit 的视觉语义 |
| 覆盖半径是否由视口和触发点计算 | 避免边角漏色 |
主题 class 是否同步到 documentElement | 避免 next-themes 等异步修正造成尾帧闪烁 |
| 清理前是否等待下一次稳定绘制 | 避免完成帧刚出现就被移除 |
| 触发器状态是否和真实主题绑定 | 避免按钮图标和页面主题错位 |
是否尊重 prefers-reduced-motion | 避免强行动效打扰用户 |
这些点看起来都不大,但它们决定了组件从“Demo 很好看”走向“项目里真的能用”。
结语
ThemeTransitionToggle 的核心并不是“用 View Transition API 做了一个圆形动画”。真正重要的是它把一次主题切换拆成了几个清晰的层次:
- 主题状态由应用真实提交
- 视觉交接交给浏览器截图层
- 几何形状只负责揭幕叙事
- 触发器只负责状态表达和微交互
- 降级路径保证功能不依赖动画
当这些边界清楚之后,深浅模式切换就不再只是改一个 class。它变成了一次可感知、可复用、可维护的界面场景交接。
这也是我很喜欢 View Transition API 的地方:它没有要求我们抛弃真实 DOM,也没有强迫我们把所有状态变化都动画化。它只是给了一个很好的中间层,让旧画面和新画面可以有一次体面交接。