element-plus 同款主题换肤动画如何实现?- 附完整源码

1,408 阅读7分钟

一. 切换主题动画

上一篇文章,我写了仅用一行代码实现全网站暗黑模式,引起一些不错的反响,其中有倔友提出,Element Plus 官网的那种切换效果是如何实现的?

image.png

例如:

Element Plus 官网的主题切换效果:

element-dark1.gif

Vben Admin 官方示例的主题切换效果:

element-dark2.gif

可见,这种动画效果在切换主题时是比较常见的,同时也是比较炫酷的,于是本篇文章我们使用简单的方案来实现一下。

本篇文章实现效果如下,文末附完整源码!

element-dark3.gif

二. 核心代码实现

以上的主题切换动画效果,主要依靠现代 CSS 和 JavaScript 的 View Transition API 技术。

参考:视图过渡 API-MDN 文档

  1. 点击位置动画:动画从点击位置开始扩散,覆盖整个页面
  2. 平滑过渡效果:使用 CSS 过渡确保颜色变化平滑

点击切换后一般会经历以下流程,我们来模拟一下:

image.png

点击按钮
  ↓
触发 toggleTheme 函数
  ↓
启动 View Transition
  ↓
切换 dark 类名
  ↓
过渡准备就绪
  ↓
计算点击位置和扩散半径
  ↓
创建圆形裁剪路径动画
  ↓
根据当前主题状态决定动画方向
  ↓
执行动画效果
  ↓
完成主题切换

核心代码如下:

2.1 toggleTheme 函数

toggoleTheme 主要实现点击按钮时的主题切换动画:

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

  transition.ready.then(() => {
    const { clientX, clientY } = event

    const radius = Math.hypot(Math.max(clientX, innerWidth - clientX), Math.max(clientY, innerHeight - clientY))

    const clipPath = [`circle(0px at ${clientX}px ${clientY}px)`, `circle(${radius}px at ${clientX}px ${clientY}px)`]

    const isDark = document.documentElement.classList.contains('dark')

    document.documentElement.animate(
      {
        clipPath: isDark ? clipPath.reverse() : clipPath
      },
      {
        duration: 450,
        easing: 'ease-in',
        pseudoElement: isDark ? '::view-transition-old(root)' : '::view-transition-new(root)'
      }
    )
  })
}

2.2 CSS 样式支持

CSS 为 View Transition API 提供必要的样式支持,确保主题切换动画能正确显示。

/* 1. 重置基础动画和混合模式 */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

/* 2. 亮色主题时的层级控制 */
::view-transition-old(root) {
  z-index: 1;
}

::view-transition-new(root) {
  z-index: 2147483646;
}

/* 3. 暗色主题时的层级反转 */
html.dark::view-transition-old(root) {
  z-index: 2147483646;
}

html.dark::view-transition-new(root) {
  z-index: 1;
}

以上两个就是实现动画的核心代码,仅需要一个 toggleTheme 函数和几行 CSS 样式支持,接下来我们来逐步分析一下。

三. toggleTheme 实现流程

3.1 启动视图过渡

使用 View Transition API 开始主题切换动画,使用 document.startViewTransition() 创建平滑的过渡效果:

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

    • 这是 View Transition API 的核心方法,它允许你在 DOM 更新时创建平滑的过渡动画
    • 返回一个 transition 对象,可以用来控制过渡过程
  2. 原理:

    • 这是主题切换动画的启动器

    • 当执行这段代码时,浏览器会:

      1. 捕获当前页面的快照(旧状态)
      2. 执行回调函数中的 DOM 更新
      3. 捕获更新后的页面快照(新状态)
      4. 在这两个状态之间创建平滑的过渡动画
    • 可以通过 transition.readytransition.finished Promise 来添加自定义动画效果

    • 圆形扩散动画就是在 transition.ready.then() 中实现的

3.2 执行过渡动画

当过渡准备就绪后,执行自定义动画效果,实现细节如下:

获取点击位置坐标

获取用户点击的坐标位置 (clientX, clientY)

// 获取点击位置坐标
const { clientX, clientY } = event

计算扩散半径

计算从点击位置到屏幕边缘的最大距离作为动画结束半径

// 计算圆形扩散半径
const radius = Math.hypot(
    Math.max(clientX, innerWidth - clientX), 
    Math.max(clientY, innerHeight - clientY)
)

计算从用户点击位置到屏幕边缘的最大距离,将其作为圆形扩散动画的结束半径。

  1. Math.max(clientX, innerWidth - clientX):计算点击位置的横坐标 clientX 到屏幕左右边缘的最大距离。
  2. Math.max(clientY, innerHeight - clientY):计算点击位置的纵坐标 clientY 到屏幕上下边缘的最大距离。
  3. Math.hypot():使用勾股定理计算上述两个最大距离构成的直角三角形的斜边长度,即从点击位置到屏幕边缘的最大直线距离。
  4. 最终得到的 radius 就是圆形扩散动画从点击点开始扩散时需要达到的半径。

裁剪路径动画

创建从点击点开始的圆形扩散动画

// 定义裁剪路径动画
const clipPath = [
  circle(0px at x y), // 初始状态:半径为0的圆(不可见)
  circle(radius at x y) // 结束状态:完全展开的圆
]

以上是创建了一个用于主题切换动画的 CSS 裁剪路径(clipPath)数组,它定义了两个状态:

  1. 第一个元素表示动画起始状态:
    • circle(0px at x y):在点击位置(x,y)创建一个半径为 0 的圆(完全不可见)
  2. 第二个元素表示动画结束状态:
    • circle(radius at x y):在相同位置创建一个半径为 radius的圆(覆盖整个屏幕)
  3. 说明:
    • x y:用户点击的屏幕坐标位置
    • radius:预先计算出的从点击点到屏幕边缘的最大距离

执行路径动画

使用 animate() 方法执行裁剪路径动画

document.documentElement.animate(
  {
    clipPath: isDark ? clipPath.reverse() : clipPath // 动画属性:裁剪路径
  },
  {
    duration: 450, // 动画持续时间450毫秒
    easing: 'ease-in', // 缓动函数:开始慢,逐渐加速
    pseudoElement: isDark ? '::view-transition-old(root)' : '::view-transition-new(root)' // 目标伪元素
  }
)
  1. 动画属性对象

    • clipPath: 使用之前定义的圆形裁剪路径
    • isDark ? clipPath.reverse() : clipPath:
      • 如果是暗黑模式(isDark=true),则反转动画方向(从大圆缩小到小圆)
      • 如果是亮色模式,则保持原方向(从小圆放大到大圆)
  2. 动画配置对象

    • duration: 450: 动画持续 450 毫秒
    • easing: 'ease-in': 使用"缓入"效果,动画开始时较慢,然后加速
    • pseudoElement: 根据当前主题选择目标伪元素
      • 暗黑模式使用 ::view-transition-old(root)(旧视图)
      • 亮色模式使用 ::view-transition-new(root)(新视图)
  3. 原理

    • 当切换主题时,浏览器会捕获当前页面的快照(旧视图)和新状态的快照(新视图)
    • 这段动画会在两个视图之间创建平滑过渡
    • 圆形裁剪路径动画会从点击点开始扩散或收缩,产生 Element Plus 官网那种视觉效果

四. CSS 样式支持

  • 动画效果依赖于前面定义的 CSS 伪元素样式
  • 通过 z-index控制新旧视图的堆叠顺序,确保正确的视觉层次
/* 1. 重置基础动画和混合模式 */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none; /* 禁用默认动画 */
  mix-blend-mode: normal; /* 使用正常混合模式 */
}

/* 2. 亮色主题时的层级控制 */
::view-transition-old(root) {
  z-index: 1; /* 旧视图(亮色)在底层 */
}

::view-transition-new(root) {
  z-index: 2147483646; /* 新视图(暗色)在顶层(最大z-index值) */
}

/* 3. 暗色主题时的层级反转 */
html.dark::view-transition-old(root) {
  z-index: 2147483646; /* 旧视图(暗色)在顶层 */
}

html.dark::view-transition-new(root) {
  z-index: 1; /* 新视图(亮色)在底层 */
}

说明:

  1. 动画控制

    • animation: none 禁用浏览器默认的淡入淡出动画
    • 为自定义的圆形扩散动画提供干净的画布
  2. 层级管理

    • 使用 z-index 控制新旧视图的堆叠顺序
    • 2147483647 是浏览器支持的最大 z-index 值(这里用了 2147483646)
    • 确保当前主题视图始终显示在最上层
  3. 主题适配

    • 通过 .dark 类选择器区分主题状态
    • 动态反转新旧视图的层级关系
    • 亮色 → 暗色:新视图在上层展开
    • 暗色 → 亮色:旧视图在上层收缩
  4. 效果优化

    • mix-blend-mode: normal 防止颜色混合异常
    • 确保圆形裁剪动画能干净利落地覆盖整个屏幕

以上 CSS 与 animate() 方法配合,共同实现了从点击位置扩散/收缩的丝滑主题切换效果。

五. 兼容性

注意:View Transition API 属于比较新的 API,在一些浏览器中可能无法使用,需要处理一下

加入以下代码优化:

// 检查浏览器是否支持 View Transition API
if (!document.startViewTransition) {
  // 不支持则直接切换主题,不添加动画
  document.documentElement.classList.toggle('dark')
  return
}

兼容性如下图所示:

image.png

完整代码

已发布在码上掘金,点击切换主题可体验效果,点击右上角码上掘金查看详情并可复制完整源码,直接使用: