Recently, I wanted to implement a black-and-white toggle animation. I saw the effect on antfu's blog and thought it was pretty good. I looked at the source code, and since I am using nuxt3, I just used it directly.
Specific code location: https://github.com/antfu/antfu.me/blob/main/src/logics/index.ts
Complete Code
<script setup lang="ts">
const mode = useColorMode()
const isDark = computed<boolean>({
get() {
return mode.value === 'dark'
},
set() {
mode.preference = isDark.value ? 'light' : 'dark'
},
})
/**
* Credit to [@hooray](https://github.com/hooray)
* @see https://github.com/vuejs/vitepress/pull/2347
*/
function toggle(event?: MouseEvent) {
const isAppearanceTransition = typeof document !== 'undefined'
// @ts-expect-error document.startViewTransition can be undefined
&& document.startViewTransition
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!isAppearanceTransition || !event) {
isDark.value = !isDark.value
return
}
const x = event.clientX
const y = event.clientY
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)
const transition = document.startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
})
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
]
document.documentElement.animate(
{
clipPath: isDark.value
? clipPath.reverse()
: clipPath,
},
{
duration: 400,
easing: 'ease-out',
pseudoElement: isDark.value
? '::view-transition-old(root)'
: '::view-transition-new(root)',
},
)
})
}
const context = {
mode,
isDark,
toggle,
}
</script>
<template>
<ClientOnly placeholder-tag="span">
<slot v-bind="context" />
</ClientOnly>
</template>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 9999;
}
.dark::view-transition-old(root) {
z-index: 9999;
}
.dark::view-transition-new(root) {
z-index: 1;
}
</style>
Code Explanation
mode
uses the useColorMode
hook to get the current color mode.
isDark
is a computed property that determines whether the current mode is dark mode and provides the functionality to toggle the mode.
The toggle
function is used to switch between dark mode and light mode.
isAppearanceTransition
checks if view transition animations are supported and if the user has not opted to reduce motion effects. If view transition animations are not supported or no event is passed, it directly toggles the mode. If view transition animations are supported, it calculates the click position and the end radius of the transition animation, and starts the view transition animation, toggling the mode during the animation.
Math.hypot
calculates the hypotenuse of a right triangle, ensuring the animation covers the entire viewport.
innerWidth
is a read-only property that returns the width of the window's document display area in pixels, including the width of the scrollbar (if any). When calculating the animation spread radius, we use innerWidth
to determine the farthest distance from the trigger point to the edge of the viewport.
transition.ready.then
defines the transition animation effect, using clipPath
to achieve a circular expansion animation effect.
Switching to dark mode: clipPath.reverse()
expands from large to small.
Switching to light mode: clipPath
expands from small to large.
Usage
<template>
...others
<DarkToggle v-if="isDesktop">
<template #default="{ toggle }">
<div cursor="pointer" @click="toggle">
<Icon size="20" name="material-symbols:light-mode-outline-rounded" dark="hidden" />
<Icon size="20" name="material-symbols:nightlight-outline-rounded" light="hidden" />
</div>
</template>
</DarkToggle>
...others
</template>