多主题系统与圆形扩散过渡:从设计令牌到「跟手」交互
本站刚刚完成了一次 UI 重构:三套完整主题(深空观测站 / 折纸动力学 / 体素世界)一键切换,切换时新主题的底色会从点击位置以圆形波纹扩散铺满全屏。这篇文章把整套实现拆开讲清楚,包括:
- 如何用 CSS 变量 +
data-*属性 驱动全站换肤 - 如何避免首屏主题闪烁(FOUC)
- 圆形扩散过渡(Circular Reveal)的三段式编排:平滑扩散 → 遮罩下换肤 → 揭示并播放标题入场
- 如何让按钮立即反馈选择,并避免导航栏在换肤后继续渐变
- 为什么你的鼠标交互「不跟手」,以及如何修复
- 一整套动画性能红线
本文按当前实现更新,代码片段省略了部分无关样式和组件依赖。主题状态与遮罩由 useUiStyle.js 管理,Hero 入场由 useHeroEntrance.js 管理。
1. 整体架构:主题 = 一组被切换的变量
核心思路一句话:组件不写死任何颜色,全部引用 CSS 变量;切换主题就是切换 <html> 上的一个属性。
/* tokens.css */
:root,
html[data-ui-style='observatory'] {
--ec-bg: #070a13; /* 页面底色 */
--ec-ink: #e9e5d8; /* 主文字 */
--ec-accent: #c9a35c; /* 强调色:黄铜 */
--ec-line: rgba(233, 229, 216, .14);
--ec-border-w: 1px;
--ec-radius: 6px;
--ec-shadow: 0 24px 60px rgba(0, 0, 0, .42);
}
html[data-ui-style='origami'] {
--ec-bg: #faf6ee;
--ec-ink: #20201e;
--ec-accent: #ff6b4a; /* 强调色:珊瑚 */
--ec-line: #20201e;
--ec-border-w: 2.5px; /* 折纸风:粗描边 */
--ec-radius: 12px;
--ec-shadow: 6px 6px 0 #20201e; /* 硬阴影 */
}这里以观测站和折纸为例。除了颜色,描边宽度、圆角、阴影风格也做成了令牌:观测站使用 1px 细线和柔和投影,折纸使用 2.5px 粗边和无模糊硬阴影。体素主题沿用同一套变量接口。
组件里只引用变量:
.blog-card {
border: var(--ec-border-w) solid var(--ec-line);
border-radius: var(--ec-radius);
background: var(--ec-surface);
box-shadow: var(--ec-shadow);
}Vue scoped 样式里怎么写主题分支?
这是实战中踩到的一个坑。最初这样写:
/* 不要用这种写法表达祖先主题条件 */
:global(html[data-ui-style='origami']) .blog-card { ... }用项目当前版本的 @vue/compiler-sfc 实测,上面的规则会编译成只剩 html[data-ui-style='origami'],后面的 .blog-card 没有保留,并不是整条规则被丢弃。对于这里的普通后代选择器,可以直接写祖先条件,让 scoped 编译给 .blog-card 添加作用域属性:
/* ✅ 编译为 html[data-ui-style='origami'] .blog-card[data-v-4b639dc0],正常工作 */
html[data-ui-style='origami'] .blog-card { ... }2. 接管框架变量:以 VitePress 为例
如果站点基于框架(本站是 VitePress),不要对着它的 class 名一个个覆盖——直接覆盖它的设计变量,导航栏、侧边栏、搜索框、分页器会全部自动跟随:
html[data-ui-style='observatory'] {
--vp-c-bg: #070a13;
--vp-c-text-1: #e9e5d8;
--vp-c-brand-1: #c9a35c;
--vp-nav-bg-color: rgba(7, 10, 19, .78);
--vp-code-block-bg: #0a0f1c;
/* ... */
}先读一遍框架的 vars.css 确认变量命名(不同版本差异很大,比如 --vp-c-brand vs --vp-c-brand-1),再批量映射,比魔改 class 健壮得多。
3. 首屏防闪烁与水合:DOM 属性和响应式状态分开恢复
主题偏好保存在 localStorage。如果等 Vue 挂载后才设置页面颜色,用户会先看到默认主题,再看到保存的主题。因此,config.mjs 在 <head> 中放置同步内联脚本,尽早写入 data-ui-style:
[
'script',
{},
`try {
var saved = localStorage.getItem('ec-ui-style')
var valid = ['observatory', 'origami', 'voxel'].includes(saved)
document.documentElement.dataset.uiStyle = valid ? saved : 'origami'
} catch (e) {
document.documentElement.dataset.uiStyle = 'origami'
}`,
]这里既检查主题白名单,也处理存储访问失败。内联脚本只设置 DOM 属性,不提前修改 Vue 的组件状态。
按钮的响应式状态要等挂载后再恢复。 服务端不知道用户的本地偏好,会渲染默认折纸按钮。如果切换器在 setup 中立即读取存储,客户端的第一次渲染就可能与服务端标记不同,导致 class 或选中属性的水合不一致。
当前 StyleSwitcher.vue 使用:
const { selectedUiStyle, initUiStyle, setUiStyle } = useUiStyle()
onMounted(initUiStyle)这样,首次水合仍按默认状态匹配;挂载后再通过正常的响应式更新恢复按钮选择。页面底色则已由 head 脚本提前设置。
4. 状态所有权:当前主题、待切换主题与过渡阶段
useUiStyle.js 中的模块级状态由切换器和首页共同消费:
import { computed, nextTick, ref } from 'vue'
const STORAGE_KEY = 'ec-ui-style'
const STYLES = ['observatory', 'origami', 'voxel']
const STYLE_BG = {
observatory: '#070a13',
origami: '#faf6ee',
voxel: '#e8f4fb',
}
const uiStyle = ref('origami')
const pendingStyle = ref(null)
const selectedUiStyle = computed(() => pendingStyle.value ?? uiStyle.value)
const transitionPhase = ref('idle')
const isSwitching = computed(() => transitionPhase.value !== 'idle')
let initialized = false
let activeTransition = null
const applyToDom = (style) => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.uiStyle = style
}
}| 状态 | 含义 | 消费者 |
|---|---|---|
uiStyle | 已经应用到页面的主题 | 首页 Hero 条件渲染 |
pendingStyle | 本次请求的目标主题,完成或取消后清空 | 过渡控制器 |
selectedUiStyle | 待切换主题优先,否则使用当前主题 | 按钮高亮与 aria-checked |
transitionPhase | idle、covering 或 revealing | 过渡控制器与 Hero 入场 |
isSwitching | 阶段是否为非空闲状态 | 派生状态,不独立写入 |
点击后,按钮可以立即反馈选择,但 uiStyle 要等圆形覆盖全屏才改变。这样不会为了即时高亮而提前挂载新场景,也不会让按钮等到动画结束才响应。
initUiStyle() 只在客户端执行一次:读取并校验保存值,再同步 uiStyle 和 DOM 属性。本站的服务端静态渲染不读取用户存储,也不调用客户端切换逻辑;如果改成按请求渲染用户主题,需要另外处理服务端状态隔离。
首页只在挂载后显示主题相关的 Hero:
<script setup>
const { uiStyle, initUiStyle } = useUiStyle()
const mounted = ref(false)
onMounted(() => {
initUiStyle()
mounted.value = true
})
</script>
<template>
<HeroVoxel v-if="mounted && uiStyle === 'voxel'" />
<HeroOrigami v-else-if="mounted && uiStyle === 'origami'" />
<HeroObservatory v-else-if="mounted" />
<div v-else class="home-hero-placeholder" aria-hidden="true"></div>
</template>占位元素预留首屏高度,减少客户端挂载时的布局变化。这组 v-if / v-else-if 同一时刻只挂载一个 Hero,切换时旧组件卸载、新组件创建。首页空闲时预拉取 three 模块,只是提前加载库代码,不会初始化三个主题,也不会提前创建体素场景。
5. 圆形扩散:覆盖、换肤、揭示与入场
当前使用的是纯色圆形遮罩。圆内首先出现目标主题的底色,圆铺满后才替换页面内容,随后遮罩淡出。它没有截取页面快照,也没有使用 View Transitions API。
idle
↓ 点击:记录 pendingStyle,按钮立即高亮
covering
├─ 820ms:目标底色的圆从点击处缓起、展开、缓收
├─ 铺满后:切换 data-ui-style 和 uiStyle,挂载目标 Hero
└─ nextTick + 双 rAF:给新 DOM 一次被遮住的绘制机会
revealing
├─ 260ms:遮罩淡出
└─ Hero 开始自己的标题上移和内容入场动画
idle
└─ 移除遮罩,取消动画句柄,清理计时器与临时标记revealing 表示遮罩正在退去,并不表示标题入场已结束。遮罩完成后,Hero 的 CSS 动画可以继续运行,由组件自己管理。
5.1 丝滑不等于缩短 duration
短时长配合起步很快的 ease-out,会让圆在前几帧就占据大部分屏幕,视觉上接近一闪而过。当前扩散使用 820ms 和 cubic-bezier(.42,0,.58,1),让起步和收尾都留有空间;遮罩淡出则单独使用 260ms 的 ease-out。
实现上不再逐帧改变 clip-path: circle(...),而是创建一个尺寸固定、border-radius: 50% 的圆,只动画 transform: scale(0 → 1)。半径取点击位置到最远角的距离,再加 2px 余量。圆的布局尺寸在开始时确定,缩放和淡出更适合由浏览器合成处理。
这不是所有设备都能达到固定帧率的保证。圆形图层尺寸、设备像素比、旧主题仍在运行的 Canvas,以及新场景初始化都会影响实际表现。这里做的关键分工是:扩散期间不换 DOM,新主题的挂载工作放在圆形完全覆盖之后。
5.2 遮罩只管理自己的动画
下面是当前 circularReveal 实现,依赖上一节定义的 STYLE_BG、transitionPhase 和 Vue 的 nextTick:
const circularReveal = (style, origin, swap, done) => {
const overlay = document.createElement('div')
const x = origin?.x ?? window.innerWidth / 2
const y = origin?.y ?? window.innerHeight / 2
const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)) + 2
overlay.setAttribute('aria-hidden', 'true')
// 固定圆形只做合成层缩放,避免每帧重算全屏 clip-path。
overlay.style.cssText = `position: fixed; left: ${x - radius}px; top: ${y - radius}px;
width: ${radius * 2}px; height: ${radius * 2}px; border-radius: 50%;
z-index: 2147483000; pointer-events: none; background: ${STYLE_BG[style]};
transform: scale(0); will-change: transform, opacity;`
const animations = []
const timers = new Set()
let paintFrame = 0
let cancelled = false
const cancel = () => {
cancelled = true
timers.forEach(id => window.clearTimeout(id))
timers.clear()
animations.forEach(animation => animation.cancel())
cancelAnimationFrame(paintFrame)
overlay.remove()
delete document.documentElement.dataset.uiStyleTransition
}
const animate = (frames, duration, easing, callback) => {
const animation = overlay.animate(frames, {
duration, easing, fill: 'forwards',
})
animations.push(animation)
let finished = false
const finish = () => {
if (finished || cancelled) return
finished = true
window.clearTimeout(timer)
timers.delete(timer)
callback()
}
const timer = window.setTimeout(finish, duration + 100)
timers.add(timer)
animation.finished.then(finish, finish)
}
document.body.appendChild(overlay)
// 不支持 WAAPI 时直接切换,不让装饰动画阻塞主题功能。
if (typeof overlay.animate !== 'function') {
overlay.remove()
return null
}
document.documentElement.dataset.uiStyleTransition = 'covering'
// 对称缓起缓收,避免强 ease-out 在前几帧就铺满视口。
animate([{ transform: 'scale(0)' }, { transform: 'scale(1)' }], 820, 'cubic-bezier(.42,0,.58,1)', () => {
swap()
nextTick(() => {
if (cancelled) return
// 给新 DOM 和 Canvas 一次被遮住的绘制机会,再同时揭示与启动标题入场。
let revealed = false
const reveal = () => {
if (cancelled || revealed) return
revealed = true
window.clearTimeout(timer)
timers.delete(timer)
cancelAnimationFrame(paintFrame)
transitionPhase.value = 'revealing'
document.documentElement.dataset.uiStyleTransition = 'revealing'
animate([{ opacity: 1 }, { opacity: 0 }], 260, 'ease-out', () => {
cancel()
done()
})
}
const timer = window.setTimeout(reveal, 150)
timers.add(timer)
paintFrame = requestAnimationFrame(() => {
paintFrame = requestAnimationFrame(reveal)
})
})
})
return { cancel }
}nextTick 等待的是 Vue 更新 DOM,不是浏览器完成绘制。双 rAF 在正常前台渲染时留出一次绘制机会,但不代表异步资源已经下载完,也不保证 Three.js 的首帧一定准备完成。
动画正常通过 animation.finished 推进,duration + 100ms 的计时器作为兜底;绘制等待也有 150ms 兜底。两条路径共用幂等检查,避免重复执行。这些时间都是调度目标,不是后台执行期限:后台页面的 rAF 和定时器都可能被浏览器节流。
旧方案曾调用 document.getAnimations() 暂停整页动画,再在遮罩退出后统一恢复。这会接管本应属于内容组件的 CSS 动画和过渡,还会把遮罩淡出、标题入场串成更长的等待。当前控制器只保存自身创建的动画句柄,不暂停其他组件。
5.3 快速切换:先取消旧过渡,再判断是否需要换肤
setUiStyle 的关键顺序如下。它位于 useUiStyle() 内,使用前面定义的状态和 circularReveal:
const setUiStyle = (style, origin) => {
if (!STYLES.includes(style) || style === selectedUiStyle.value) return
activeTransition?.cancel()
activeTransition = null
pendingStyle.value = null
transitionPhase.value = 'idle'
try {
window.localStorage.setItem(STORAGE_KEY, style)
} catch {
// 持久化失败不阻止切换。
}
// 点回正在显示的主题也必须先取消旧请求。
if (style === uiStyle.value) return
const swap = () => {
applyToDom(style)
uiStyle.value = style
}
const done = () => {
activeTransition = null
pendingStyle.value = null
transitionPhase.value = 'idle'
}
if (typeof document === 'undefined' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
swap()
return
}
pendingStyle.value = style
transitionPhase.value = 'covering'
activeTransition = circularReveal(style, origin, swap, done)
if (!activeTransition) {
swap()
done()
}
}这里有一个容易漏掉的路径:页面还显示折纸,用户先点体素,又在扩散结束前点回折纸。第二次点击必须取消体素请求,然后保留折纸;不能因为“折纸就是当前主题”而提前返回。
取消时会清理本次遮罩的动画、rAF、计时器和临时属性,并设置 cancelled。即使 animation.cancel() 导致 finished Promise 拒绝,旧回调也会先检查取消状态,不会继续换肤。新请求不再依赖旧请求的完成事件。
保存偏好发生在请求时,因此切换尚未结束就刷新,也会恢复最后一次选择。存储写入失败不影响页面切换。
5.4 标题上移由 Hero 自己启动
把 entered 直接初始化为 true 虽然能让内容立刻可见,但也会跳过标题上移。当前每个 Hero 都从隐藏态开始,使用 useHeroEntrance.js 等待揭示阶段:
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useUiStyle } from './useUiStyle'
// 每次挂载都从隐藏态开始;主题切换时等遮罩开始退去,再播放组件自己的 CSS 入场。
export function useHeroEntrance() {
const entered = ref(false)
const { transitionPhase } = useUiStyle()
let frame = 0
const enter = () => {
if (entered.value || frame || transitionPhase.value === 'covering') return
// 初次加载也要先绘制隐藏态,避免首帧就 is-in 而没有 transition。
frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
frame = 0
entered.value = true
})
})
}
onMounted(enter)
watch(transitionPhase, enter, { flush: 'post' })
onBeforeUnmount(() => cancelAnimationFrame(frame))
return entered
}三个 Hero 都使用 const entered = useHeroEntrance(),并把它绑定到根节点的 is-in class。组件原有 CSS 负责实际入场,例如观测站的标题行:
.lm {
display: block;
overflow: hidden;
}
.lm > span {
display: block;
transform: translateY(120%);
transition: transform 1.05s cubic-bezier(.22, 1, .36, 1);
}
.is-in .lm > span {
transform: translateY(0);
}切换时,Hero 在 covering 阶段挂载,但暂不设置 is-in;进入 revealing 后,再经过双 rAF 启动入场,让浏览器有机会先绘制隐藏态。首次打开页面处于 idle,也会走同样的初始绘制过程。
这不会等到遮罩完全消失才播放标题。遮罩淡出和标题入场有一段重叠,避免露出空白页后再等待动画开始。组件卸载时取消未执行的 rAF,切回来则创建新的入场状态。
5.5 避免顶部导航再补一段颜色渐变
VitePress 当前版本的导航背景自带 0.5s transition,菜单等子元素也有颜色过渡。切换主题变量后,它们仍在从旧颜色插值到新颜色;遮罩淡出只有 260ms,用户就会看到导航栏继续慢慢变色。
过渡控制器在 <html> 上设置临时的 data-ui-style-transition 属性,导航样式据此暂时关闭自身的 transition:
html[data-ui-style-transition] .VPNav,
html[data-ui-style-transition] .VPNav *,
html[data-ui-style-transition] .VPNav *::before,
html[data-ui-style-transition] .VPNav *::after {
transition: none !important;
}这条规则仅覆盖导航区域,不会关闭 Hero 的标题动画。遮罩完成或取消时都会移除临时属性,导航原有的悬停和展开过渡随之恢复。
5.6 点击原点与降级路径
鼠标点击使用 clientX / clientY。键盘触发的 click 通常没有有效指针坐标,因此 StyleSwitcher.vue 在 event.detail === 0 时改用按钮中心:
const handleSwitch = (key, event) => {
const { left, top, width, height } = event.currentTarget.getBoundingClientRect()
setUiStyle(key, event.detail === 0
? { x: left + width / 2, y: top + height / 2 }
: { x: event.clientX, y: event.clientY })
}未提供原点时,composable 使用视口中心。遮罩设置 aria-hidden="true" 和 pointer-events: none,不参与无障碍内容,也不阻止用户继续选择其他主题。
prefers-reduced-motion: reduce 下直接换肤,Hero 的 CSS 也有对应的减少动态效果规则;没有 Element.animate() 时则移除遮罩并直接应用主题。动画能力不足不应阻止主题功能。
6. 「跟手」交互:直写 vs 过渡拖尾
很多网站喜欢给鼠标跟随效果加缓冲:
/* ❌ 不跟手的根源:每次 pointermove 都触发一段 0.8s 的追赶动画 */
.parallax-layer {
transition: transform 0.8s cubic-bezier(.22,1,.36,1);
}指针在动,元素永远在"追"指针,视觉上就是拖沓、延迟。所谓跟手,就是指针位移和元素位移之间没有时间差:
// ✅ rAF 节流 + 直写 transform,无过渡
let frame = null
let px = 0, py = 0
const apply = () => {
frame = null
layer.style.transform = `translate3d(${px * 26}px, ${py * 18}px, 0)`
}
el.addEventListener('pointermove', (e) => {
const rect = el.getBoundingClientRect()
px = (e.clientX - rect.left) / rect.width - 0.5 // 归一化到 [-0.5, 0.5]
py = (e.clientY - rect.top) / rect.height - 0.5
if (!frame) frame = requestAnimationFrame(apply) // 每帧最多写一次
}, { passive: true })那什么时候该用过渡?——归位动画。鼠标离开时让它弹回去,此时过渡是在"两个稳定状态"之间播放,不存在追手问题:
el.addEventListener('pointerleave', () => {
layer.style.transition = 'transform .55s cubic-bezier(.34,1.56,.64,1)'
layer.style.transform = ''
setTimeout(() => { layer.style.transition = '' }, 560) // 用完即拆
})一句话总结:跟手过程中直写,状态切换间过渡。
7. 性能与验证
这套方案把主题切换和内容动画的职责分开,但仍需要在实际页面验证,不能只根据动画属性推断流畅度。
| 检查项 | 当前处理 |
|---|---|
| 圆形扩散与淡出 | 固定尺寸的圆使用 transform 和 opacity,避免逐帧修改布局尺寸;是否合成加速仍取决于浏览器与图层条件 |
| 新主题挂载 | 圆铺满后才更新 uiStyle,把 DOM 替换和场景初始化放在遮罩下 |
| 标题入场 | 组件拥有自己的隐藏态与 CSS 过渡,等待揭示阶段再开始 |
| 顶部导航 | 主题过渡期间关闭导航自身的 transition,完成或取消后恢复 |
| Canvas 分辨率 | 观测站 DPR 上限为 1.6,体素为 1.5,并根据可见性减少无效绘制 |
| 指针和滚动交互 | 使用 rAF 合并频繁更新;不调用 preventDefault 的监听才适合标记为 passive |
| 生命周期 | 清理遮罩动画、计时器与 rAF;各组件负责自己的监听、观察者和图形资源 |
| 后台与能力降级 | 完成事件配合幂等计时器兜底,减少动态效果或缺少 WAAPI 时直接换肤 |
现有回归测试可以运行:
node --test scripts/use-ui-style.test.mjs scripts/hero-entrance.test.mjs11 项测试覆盖按钮即时反馈、点回当前主题、取消后的旧回调、减少动态效果、缺少 WAAPI、资源清理、导航过渡标记,以及 Hero 的等待揭示、首次入场、重新挂载和卸载清理。
生产预览还在桌面与手机尺寸下进行了逐帧检查:三个主题的标题都有实际位移过渡,换肤后导航额外的颜色过渡为零,同一时刻只挂载一个 Hero。这些检查验证了编排行为,不等于所有设备都能稳定达到 60fps;3D 初始化和实际硬件上的掉帧仍应分别测量。
8. 实现职责与阅读顺序
相关文件都位于 docs/.vitepress/ 下:
| 文件 | 职责 |
|---|---|
theme/tokens.css | 三套设计令牌与 VitePress 变量映射 |
config.mjs | 首屏前恢复 data-ui-style |
composables/useUiStyle.js | 已应用主题、待切换主题、过渡阶段与遮罩清理 |
components/shared/StyleSwitcher.vue | 挂载后恢复按钮状态,传递点击原点,立即反馈选择 |
components/home/AppHome.vue | 只挂载当前主题的 Hero;空闲时预拉取 Three.js 模块 |
composables/useHeroEntrance.js | 协调首次绘制、揭示阶段与组件入场 |
components/home/Hero*.vue | 各自的标题动画、Canvas 或 Three.js 场景与资源生命周期 |
theme/style.css | 主题切换期间抑制导航自身的过渡 |
调试时可以沿着这条链检查:点击是否立即更新 selectedUiStyle,圆是否铺满后才改变 uiStyle,新 Hero 是否先处于隐藏态,revealing 是否启动标题入场,以及完成或取消后是否还留有遮罩和临时属性。这样能区分状态错误、动画时序问题和场景渲染开销,而不是把所有不流畅都归结为 duration 不合适。