unocss 官网有意思的主题切换动画
最近项目里经常使用 unocss,下意识点了下 unocss 官网的主题切换动画,有点意思,搞一下。

让我们开始吧
快速创建一个 vue 项目
过程略
接下来就是关键的 ViewTransition
这是一个在 2025 年 10 月新增的 Web Api。
官方的解释是:
View Transitions API 的 ViewTransition 接口表示视图过渡,并提供了在过渡到达不同状态时运行代码的功能(例如,准备运行动画,或动画完成),或跳过视图过渡。
简单理解就是,这个 API 可以帮助我们创建两个伪元素,分别代表了过渡前和过渡后的状态。
也就是::view-transition-old(root)和::view-transition-new(root)。
这里的 root 指的是根元素,也就是 html。
当然你可以通过使用view-transition-name来为指定的元素创建对应的伪元素。
这里不需要这么做,我们需要的是整个页面,有兴趣的话可以尝试一下。
接下来我们就需要用startViewTransition来开始一个新的过渡,并返回一个ViewTransition对象。
最后我们需要等待伪元素创建完成后,使用Element.animate为这两个伪元素添加动画。
ok, lets coding。
code start
使用The 4 color formula创建一组主题色,并在main.css中定义颜色变量,记得引入main.css。
            
            
              css
              
              
            
          
          :root {
  --color-primary: hsl(191, 50%, 90%);
  --color-secondary: hsl(191, 50%, 10%);
  --color-tertiary: hsl(251, 80%, 20%);
  --color-accent: hsl(131, 80%, 20%);
}
:root[class="dark"] {
  --color-primary: hsl(191, 50%, 10%);
  --color-secondary: hsl(191, 50%, 90%);
  --color-tertiary: hsl(251, 80%, 80%);
  --color-accent: hsl(131, 80%, 80%);
}在uno.config.ts中配置主题,并添加@unocss/transformer-directives插件,启用@apply指令。
            
            
              ts
              
              
            
          
          import { defineConfig } from "unocss";
import transformerDirectives from "@unocss/transformer-directives";
export default defineConfig({
  theme: {
    colors: {
      primary: "var(--color-primary)",
      secondary: "var(--color-secondary)",
      tertiary: "var(--color-tertiary)",
      accent: "var(--color-accent)",
    },
  },
  transformers: [transformerDirectives()],
});为 HTML 设置主题色。
            
            
              css
              
              
            
          
          html {
  @apply bg-primary text-secondary;
}初始配置结束,接下来为主题色添加过渡动画。
我就在app.vue中开始演示,我喜欢使用 VueUse 中的 useColorMode 来监听主题状态。
            
            
              ts
              
              
            
          
          const colorMode = useColorMode();
const nextTheme = computed(() =>
  colorMode.value === "dark" ? "light" : "dark"
);
const switchTheme = () => {
  colorMode.value = nextTheme.value;
};创建一个按钮,点击切换主题,我这里只展示点击方法。
            
            
              ts
              
              
            
          
          const handleThemeToggle = (event: MouseEvent) => {
  // 检查浏览器是否支持View Transition API
  if (!document.startViewTransition) {
    switchTheme();
    return;
  }
  startViewTransition(event);
};startViewTransition是过渡动画实现的核心方法,让我们一点一点拆解。
首先获得鼠标点击的位置,以及计算点击动画圆形的半径。
            
            
              ts
              
              
            
          
          const startViewTransition = (event: MouseEvent) => {
  const x = event.clientX;
  const y = event.clientY;
  const endRadius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y)
  );
};然后使用document.startViewTransition开始一个新的过渡,并在回调函数中切换主题。
            
            
              ts
              
              
            
          
          const startViewTransition = (event: MouseEvent) => {
  const x = event.clientX;
  const y = event.clientY;
  const endRadius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y)
  );
  const transition = document.startViewTransition(() => {
    switchTheme();
  });
};接下来等待过渡创建完成后,使用Element.animate为伪元素添加动画,我们先尝试只为::view-transition-new(root)添加动画。
            
            
              ts
              
              
            
          
          const startViewTransition = (event: MouseEvent) => {
  const x = event.clientX;
  const y = event.clientY;
  const endRadius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y)
  );
  const transition = document.startViewTransition(() => {
    switchTheme();
  });
  transition.ready.then(() => {
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 600,
        easing: "cubic-bezier(.76,.32,.29,.99)",
        pseudoElement: "::view-transition-new(root)",
      }
    );
  });
};如果你这时直接点击按钮时,会发现圆形动画效果已经出现,但是在圆形动画结束前,主题切换已经完成了。
这是因为::view-transition-old(root)和::view-transition-new(root)默认具有一个过渡效果,我们需要将其禁用。
            
            
              css
              
              
            
          
          ::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
}再试一下,我们会得到这样一个效果。

已经很接近了,不是么?
接下来我们要考虑的是:
- 由light->dark的时,保持现在的过渡动画。
- 由dark->light的时,添加一个反向的过渡动画。
由于::view-transition-new(root)默认是在::view-transition-old(root)之上显示。
而我们在dark->light切换时,需要的是为::view-transition-old(root)添加动画。
因此,我们需要修改他们的z-index属性,让他们在合适的层级上显示。
让我们在main.css中定义两个变量,并引用他们。
            
            
              css
              
              
            
          
          :root {
  --color-primary: hsl(191, 50%, 90%);
  --color-secondary: hsl(191, 50%, 10%);
  --color-tertiary: hsl(251, 80%, 20%);
  --color-accent: hsl(131, 80%, 20%);
  --view-transition-old-zindex: 9999;
  --view-transition-new-zindex: 1;
}
:root[class="dark"] {
  --color-primary: hsl(191, 50%, 10%);
  --color-secondary: hsl(191, 50%, 90%);
  --color-tertiary: hsl(251, 80%, 80%);
  --color-accent: hsl(131, 80%, 80%);
  --view-transition-old-zindex: 1;
  --view-transition-new-zindex: 9999;
}
::view-transition-old(root) {
  z-index: var(--view-transition-old-zindex);
}
::view-transition-new(root) {
  z-index: var(--view-transition-new-zindex);
}接下来让我们修改startViewTransition方法,根据当前主题状态,为不同的伪元素添加动画。
            
            
              ts
              
              
            
          
          const startViewTransition = (event: MouseEvent) => {
  const x = event.clientX;
  const y = event.clientY;
  const endRadius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y)
  );
  const transition = document.startViewTransition(() => {
    switchTheme();
  });
  transition.ready.then(() => {
    const isDark = colorMode.value === "dark";
    const clipPath = [
      `circle(0px at ${x}px ${y}px)`,
      `circle(${endRadius}px at ${x}px ${y}px)`,
    ];
    document.documentElement.animate(
      {
        clipPath: isDark ? clipPath : clipPath.reverse(),
      },
      {
        duration: 600,
        easing: "cubic-bezier(.76,.32,.29,.99)",
        pseudoElement: isDark
          ? "::view-transition-new(root)"
          : "::view-transition-old(root)",
      }
    );
  });
};大功告成!

完整代码如下
            
            
              css
              
              
            
          
          :root {
  --color-primary: hsl(191, 50%, 90%);
  --color-secondary: hsl(191, 50%, 10%);
  --color-tertiary: hsl(251, 80%, 20%);
  --color-accent: hsl(131, 80%, 20%);
  --view-transition-old-zindex: 9999;
  --view-transition-new-zindex: 1;
}
:root[class="dark"] {
  --color-primary: hsl(191, 50%, 10%);
  --color-secondary: hsl(191, 50%, 90%);
  --color-tertiary: hsl(251, 80%, 80%);
  --color-accent: hsl(131, 80%, 80%);
  --view-transition-old-zindex: 1;
  --view-transition-new-zindex: 9999;
}
html {
  @apply bg-primary text-secondary;
}
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
}
::view-transition-old(root) {
  z-index: var(--view-transition-old-zindex);
}
::view-transition-new(root) {
  z-index: var(--view-transition-new-zindex);
}
            
            
              ts
              
              
            
          
          const colorMode = useColorMode();
const nextTheme = computed(() =>
  colorMode.value === "dark" ? "light" : "dark"
);
const switchTheme = () => {
  colorMode.value = nextTheme.value;
};
const handleThemeToggle = (event: MouseEvent) => {
  // 检查浏览器是否支持View Transition API
  if (!document.startViewTransition) {
    switchTheme();
    return;
  }
  startViewTransition(event);
};
const startViewTransition = (event: MouseEvent) => {
  const x = event.clientX;
  const y = event.clientY;
  const endRadius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y)
  );
  const transition = document.startViewTransition(() => {
    switchTheme();
  });
  transition.ready.then(() => {
    const isDark = colorMode.value === "dark";
    const clipPath = [
      `circle(0px at ${x}px ${y}px)`,
      `circle(${endRadius}px at ${x}px ${y}px)`,
    ];
    document.documentElement.animate(
      {
        clipPath: isDark ? clipPath : clipPath.reverse(),
      },
      {
        duration: 600,
        easing: "cubic-bezier(.76,.32,.29,.99)",
        pseudoElement: isDark
          ? "::view-transition-new(root)"
          : "::view-transition-old(root)",
      }
    );
  });
};到最后啦
你觉得这个效果怎么样,有没有什么更新奇的想法,欢迎在评论区分享!