Skip to content

多主题系统与圆形扩散过渡:从设计令牌到「跟手」交互 ​

本站刚刚完成了一次 UI 重构:三套完整主题(深空观测站 / 折纸动力学 / 体素世界)一键切换,切换时新主题的底色会从点击位置以圆形波纹扩散铺满全屏。这篇文章把整套实现拆开讲清楚,包括:

  • 如何用 CSS 变量 + data-* 属性 驱动全站换肤
  • 如何避免首屏主题闪烁(FOUC)
  • 圆形扩散过渡(Circular Reveal)的三段式编排:平滑扩散 → 遮罩下换肤 → 揭示并播放标题入场
  • 如何让按钮立即反馈选择,并避免导航栏在换肤后继续渐变
  • 为什么你的鼠标交互「不跟手」,以及如何修复
  • 一整套动画性能红线

本文按当前实现更新,代码片段省略了部分无关样式和组件依赖。主题状态与遮罩由 useUiStyle.js 管理,Hero 入场由 useHeroEntrance.js 管理。


1. 整体架构:主题 = 一组被切换的变量 ​

核心思路一句话:组件不写死任何颜色,全部引用 CSS 变量;切换主题就是切换 <html> 上的一个属性。

css
/* 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 粗边和无模糊硬阴影。体素主题沿用同一套变量接口。

组件里只引用变量:

css
.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 样式里怎么写主题分支? ​

这是实战中踩到的一个坑。最初这样写:

css
/* 不要用这种写法表达祖先主题条件 */
:global(html[data-ui-style='origami']) .blog-card { ... }

用项目当前版本的 @vue/compiler-sfc 实测,上面的规则会编译成只剩 html[data-ui-style='origami'],后面的 .blog-card 没有保留,并不是整条规则被丢弃。对于这里的普通后代选择器,可以直接写祖先条件,让 scoped 编译给 .blog-card 添加作用域属性:

css
/* ✅ 编译为 html[data-ui-style='origami'] .blog-card[data-v-4b639dc0],正常工作 */
html[data-ui-style='origami'] .blog-card { ... }

2. 接管框架变量:以 VitePress 为例 ​

如果站点基于框架(本站是 VitePress),不要对着它的 class 名一个个覆盖——直接覆盖它的设计变量,导航栏、侧边栏、搜索框、分页器会全部自动跟随:

css
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:

js
[
  '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 使用:

js
const { selectedUiStyle, initUiStyle, setUiStyle } = useUiStyle()
onMounted(initUiStyle)

这样,首次水合仍按默认状态匹配;挂载后再通过正常的响应式更新恢复按钮选择。页面底色则已由 head 脚本提前设置。

4. 状态所有权:当前主题、待切换主题与过渡阶段 ​

useUiStyle.js 中的模块级状态由切换器和首页共同消费:

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
transitionPhaseidle、covering 或 revealing过渡控制器与 Hero 入场
isSwitching阶段是否为非空闲状态派生状态,不独立写入

点击后,按钮可以立即反馈选择,但 uiStyle 要等圆形覆盖全屏才改变。这样不会为了即时高亮而提前挂载新场景,也不会让按钮等到动画结束才响应。

initUiStyle() 只在客户端执行一次:读取并校验保存值,再同步 uiStyle 和 DOM 属性。本站的服务端静态渲染不读取用户存储,也不调用客户端切换逻辑;如果改成按请求渲染用户主题,需要另外处理服务端状态隔离。

首页只在挂载后显示主题相关的 Hero:

vue
<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。

txt
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:

js
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:

js
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 等待揭示阶段:

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 负责实际入场,例如观测站的标题行:

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:

css
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 时改用按钮中心:

js
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 过渡拖尾 ​

很多网站喜欢给鼠标跟随效果加缓冲:

css
/* ❌ 不跟手的根源:每次 pointermove 都触发一段 0.8s 的追赶动画 */
.parallax-layer {
  transition: transform 0.8s cubic-bezier(.22,1,.36,1);
}

指针在动,元素永远在"追"指针,视觉上就是拖沓、延迟。所谓跟手,就是指针位移和元素位移之间没有时间差:

js
// ✅ 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 })

那什么时候该用过渡?——归位动画。鼠标离开时让它弹回去,此时过渡是在"两个稳定状态"之间播放,不存在追手问题:

js
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 时直接换肤

现有回归测试可以运行:

bash
node --test scripts/use-ui-style.test.mjs scripts/hero-entrance.test.mjs

11 项测试覆盖按钮即时反馈、点回当前主题、取消后的旧回调、减少动态效果、缺少 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 不合适。