Search

Search pages, projects, posts...

给个人博客加上圆形扩散的主题切换动画

给个人博客加上圆形扩散的主题切换动画

用 View Transitions API 和 clip-path 实现从点击位置扩散、向点击位置收拢的深浅主题切换,并处理降级、键盘操作和动画收尾。

·King3

深浅主题能正常切换以后,我很快又发现一个问题:页面变化太生硬。背景、正文和卡片同时换色,动作像少了一帧。

我想要的效果很明确:切到深色时,浅色页面向按钮的点击位置收拢;切回浅色时,浅色页面再从同一个位置展开。浏览器提供的 View Transition API 正好能保存主题切换前后的页面快照,我们只需要控制两张快照如何交接。

先看效果

下面的演示可以切换圆形动画、默认淡入淡出和直接切换。打开慢速模式后,收拢与扩散的方向会更容易看清。

把三种切换方式放在一起比较

先在下面选择模式,再点击预览右上角的主题按钮。

过渡方式

View Transition 保存了什么

普通的主题切换通常只是修改根节点上的 class:

ts
document.documentElement.classList.toggle('dark')

把这次 DOM 更新放进 document.startViewTransition() 后,浏览器会先截取旧页面,再执行更新,接着截取新页面:

ts
const transition = document.startViewTransition(() => {
  document.documentElement.classList.toggle('dark')
})

两张快照分别对应下面两个伪元素:

css
::view-transition-old(root) {
}

::view-transition-new(root) {
}

浏览器默认让旧快照淡出、新快照淡入。圆形主题动画直接用 clip-path 改变整张快照的可见范围,不需要逐个处理页面元素。

从点击位置算出圆的半径

event.clientXevent.clientY 是点击位置相对于视口的坐标。视口尺寸则可以从 innerWidthinnerHeight 取得,因此它们可以直接放进同一套计算。

ts
const { clientX: x, clientY: y } = event

const radius = Math.hypot(
  Math.max(x, innerWidth - x),
  Math.max(y, innerHeight - y)
)

横向距离取圆心到左右边界中的较大值,纵向距离取到上下边界中的较大值。Math.hypot() 根据这两条边算出的斜边,就是圆心到最远角的距离。

接着生成从 0 到最大半径的两帧裁切路径:

ts
const clipPath = [
  `circle(0px at ${x}px ${y}px)`,
  `circle(${radius}px at ${x}px ${y}px)`
]

clip-path 会保留圆内的内容并隐藏圆外的部分。半径从 0 增大时,快照从点击位置展开;反转数组后,快照就会向点击位置收拢。

深色收拢,浅色扩散

主题更新完成后,可以根据目标主题决定裁切哪张快照:

  • 进入深色主题时,让旧的浅色快照位于上层,再把它从最大圆裁到 0。
  • 进入浅色主题时,裁切新的浅色快照,让它从 0 扩展到最大圆。

先关闭新旧快照自带的淡入淡出。进入深色主题时,再提高旧快照的层级:

css
html[data-theme-transition]::view-transition-old(root),
html[data-theme-transition]::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

html[data-theme-transition='dark']::view-transition-old(root) {
  z-index: 1;
}

transition.ready 会在新旧快照准备好后完成。主题 class 此时已经更新,可以开始裁切快照。具体时机见 MDN 的 ViewTransition.ready

ts
const enteringDark = nextTheme === 'dark'

document.documentElement.animate(
  {
    clipPath: enteringDark ? [...clipPath].reverse() : clipPath
  },
  {
    duration: 500,
    fill: 'forwards',
    pseudoElement: enteringDark
      ? '::view-transition-old(root)'
      : '::view-transition-new(root)'
  }
)

pseudoElement 决定动画落在哪张快照上。这里还设置了 fill: 'forwards',让裁切状态保持在最后一帧。我在测试深色收拢时遇到过一次白闪:半径已经缩到 0,旧浅色快照却在被移除前恢复成完整页面。原因是 Element.animate() 默认不会保留结束帧。保持最后一帧后,旧快照会停在 circle(0px),直到 View Transition 将它移除。

整理成一个通用函数

主题状态不一定来自根节点 class,也可能放在 React、Vue 或其他状态容器里。下面的函数只要求调用方提供当前主题和一个同步提交主题的函数。

ts
type Theme = 'light' | 'dark'
type CommitTheme = (theme: Theme) => void
type ClickPosition = Pick<MouseEvent, 'clientX' | 'clientY' | 'detail'>

let isTransitioning = false

function getOrigin(event: ClickPosition, button: HTMLElement) {
  if (event.detail !== 0) {
    return { x: event.clientX, y: event.clientY }
  }

  const rect = button.getBoundingClientRect()

  return {
    x: rect.left + rect.width / 2,
    y: rect.top + rect.height / 2
  }
}

export async function toggleThemeWithTransition(
  event: ClickPosition,
  button: HTMLElement,
  currentTheme: Theme,
  commitTheme: CommitTheme
) {
  if (isTransitioning) return

  const nextTheme: Theme = currentTheme === 'dark' ? 'light' : 'dark'
  const root = document.documentElement
  const { x, y } = getOrigin(event, button)
  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches

  if (!document.startViewTransition || reduceMotion) {
    commitTheme(nextTheme)
    return
  }

  isTransitioning = true
  root.dataset.themeTransition = nextTheme
  let animation: Animation | undefined

  try {
    const transition = document.startViewTransition(() => {
      commitTheme(nextTheme)
    })

    await transition.ready

    const radius = Math.hypot(
      Math.max(x, innerWidth - x),
      Math.max(y, innerHeight - y)
    )
    const clipPath = [
      `circle(0px at ${x}px ${y}px)`,
      `circle(${radius}px at ${x}px ${y}px)`
    ]
    const enteringDark = nextTheme === 'dark'

    animation = root.animate(
      {
        clipPath: enteringDark ? [...clipPath].reverse() : clipPath
      },
      {
        duration: 500,
        fill: 'forwards',
        pseudoElement: enteringDark
          ? '::view-transition-old(root)'
          : '::view-transition-new(root)'
      }
    )

    await transition.finished
  } finally {
    animation?.cancel()
    delete root.dataset.themeTransition
    isTransitioning = false
  }
}

getOrigin() 对鼠标和键盘做了区分。鼠标点击使用真实落点;键盘触发的 click 没有可靠的指针位置,所以回退到传入按钮的中心。显式传入按钮,也能让同一个函数接收 React 事件。

不支持 View Transition,或者系统开启了减少动态效果时,函数会直接提交新主题。圆形动画只是增强,不能成为切换主题的前置条件。

原生页面可以这样接入:

ts
const root = document.documentElement
const button = document.querySelector<HTMLButtonElement>('#theme-toggle')

if (button) {
  button.addEventListener('click', (event) => {
    const currentTheme = root.classList.contains('dark') ? 'dark' : 'light'

    void toggleThemeWithTransition(event, button, currentTheme, (theme) => {
      root.classList.toggle('dark', theme === 'dark')
    }).catch(console.error)
  })
}

接入 React 和 next-themes

通用函数要求主题更新在 View Transition 的回调内完成。React 可能批量提交状态,因此接入 next-themes 时,可以在这个边界使用 flushSync()

tsx
function handleThemeClick(event: React.MouseEvent<HTMLButtonElement>) {
  if (resolvedTheme !== 'dark' && resolvedTheme !== 'light') return

  void toggleThemeWithTransition(
    event,
    event.currentTarget,
    resolvedTheme,
    (theme) => {
      flushSync(() => setTheme(theme))
    }
  ).catch(console.error)
}

按钮本身只需要把 handleThemeClick 传给 onClick。圆心计算、快照方向和降级逻辑仍然留在通用函数里,换框架时只需替换 commitTheme