feat: 添加主题切换扩散动画

This commit is contained in:
2026-07-17 00:55:31 +08:00
parent 991264c32c
commit 3bde132a6d
2 changed files with 204 additions and 6 deletions
+21
View File
@@ -87,6 +87,27 @@ body {
-moz-osx-font-smoothing: grayscale;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
z-index: 1;
}
::view-transition-old(root) {
z-index: 0;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
/* 入场动画 */
@keyframes fade-in-up {
from {
+183 -6
View File
@@ -2,7 +2,20 @@
import { Icon } from '@iconify/vue'
const colorMode = useColorMode()
const buttonRef = ref<HTMLButtonElement | null>(null)
const isAnimating = ref(false)
const isMounted = ref(false)
type Mode = 'system' | 'dark' | 'light'
type ViewTransition = {
ready: Promise<void>
finished: Promise<void>
updateCallbackDone: Promise<void>
}
type DocumentWithViewTransition = Document & {
startViewTransition?: (callback: () => void | Promise<void>) => ViewTransition
}
const iconMap: Record<Mode, string> = {
system: 'mdi:monitor',
@@ -22,27 +35,191 @@ const cycleOrder = computed<Mode[]>(() => {
})
const currentMode = computed(() => colorMode.preference as Mode)
const currentIcon = computed(() => iconMap[currentMode.value] || iconMap.system)
const currentLabel = computed(() => labelMap[currentMode.value] || labelMap.system)
const displayMode = computed<Mode>(() => isMounted.value ? currentMode.value : 'system')
const currentIcon = computed(() => iconMap[displayMode.value] || iconMap.system)
const currentLabel = computed(() => labelMap[displayMode.value] || labelMap.system)
const resolvedMode = computed(() => colorMode.value)
const displayResolvedMode = computed(() => isMounted.value ? resolvedMode.value : 'light')
function cycle() {
onMounted(() => {
isMounted.value = true
})
function getNextMode() {
const order = cycleOrder.value
const idx = order.indexOf(currentMode.value)
colorMode.preference = order[(idx + 1) % order.length]
return order[(idx + 1) % order.length]
}
function getTransitionOrigin() {
const rect = buttonRef.value?.getBoundingClientRect()
if (!rect) {
return {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
}
}
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
}
}
function getMaxRadius(x: number, y: number) {
return Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
)
}
function wait(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms))
}
async function cycle() {
if (isAnimating.value) return
const nextMode = getNextMode()
if (!import.meta.client) {
colorMode.preference = nextMode
return
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const documentWithTransition = document as DocumentWithViewTransition
if (prefersReducedMotion || !documentWithTransition.startViewTransition) {
colorMode.preference = nextMode
return
}
const { x, y } = getTransitionOrigin()
const endRadius = getMaxRadius(x, y)
isAnimating.value = true
const fallbackTimer = window.setTimeout(() => {
isAnimating.value = false
}, 900)
const applyMode = async () => {
colorMode.preference = nextMode
await nextTick()
}
let transition: ViewTransition
try {
transition = documentWithTransition.startViewTransition(applyMode)
} catch {
await applyMode()
window.clearTimeout(fallbackTimer)
isAnimating.value = false
return
}
try {
await transition.ready
const animation = document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 620,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
pseudoElement: '::view-transition-new(root)',
} as KeyframeAnimationOptions
)
await Promise.race([
animation.finished.catch(() => undefined),
wait(700),
])
void transition.finished.catch(() => undefined)
} catch {
await transition.updateCallbackDone.catch(applyMode)
} finally {
window.clearTimeout(fallbackTimer)
isAnimating.value = false
}
}
</script>
<template>
<button
ref="buttonRef"
@click="cycle"
class="inline-flex items-center justify-center rounded-md w-8 h-8 transition-colors"
class="theme-toggle inline-flex items-center justify-center rounded-md w-8 h-8 transition-colors"
style="color: var(--color-text-secondary)"
:class="[
'hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]',
]"
:disabled="isAnimating"
:aria-label="currentLabel"
:aria-pressed="displayResolvedMode === 'dark'"
:title="currentLabel"
>
<Icon :icon="currentIcon" class="w-5 h-5" />
<span
class="theme-toggle-icon"
:class="{ 'is-dark': displayResolvedMode === 'dark' }"
>
<Icon
:key="currentIcon"
:icon="currentIcon"
class="w-5 h-5"
/>
</span>
</button>
</template>
<style scoped>
.theme-toggle {
overflow: hidden;
isolation: isolate;
}
.theme-toggle:disabled {
cursor: wait;
}
.theme-toggle-icon {
display: grid;
place-items: center;
transition:
transform 0.28s ease,
opacity 0.2s ease;
}
.theme-toggle-icon.is-dark {
transform: rotate(-18deg) scale(0.96);
}
.theme-toggle-icon :deep(svg) {
animation: theme-icon-pop 0.28s ease;
}
@keyframes theme-icon-pop {
from {
opacity: 0;
transform: rotate(-45deg) scale(0.72);
}
to {
opacity: 1;
transform: rotate(0deg) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.theme-toggle-icon,
.theme-toggle-icon :deep(svg) {
animation: none;
transition: none;
}
}
</style>