<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>深色模式切换动画(修正版)</title>
<!-- Element Plus 样式与暗色变量 -->
<link
rel="stylesheet"
href="https://unpkg.com/element-plus/dist/index.css"
/>
<link
rel="stylesheet"
href="https://unpkg.com/element-plus/theme-chalk/dark/css-vars.css"
/>
<style>
:root {
--bg-color: #ffffff;
--text-color-primary: #303133;
--font-family: 'Helvetica Neue', Helvetica, 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
--border-color: #4c4d4f;
}
/* 暗色主题变量覆盖(当 html 上有 .dark 时生效) */
html.dark {
--bg-color: #0f1113;
--text-color-primary: #e6e6e6;
--border-color: #303235;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg-color);
color: var(--text-color-primary);
transition: color 300ms ease, background-color 300ms ease;
font-family: var(--font-family), -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji';
}
.toolbar {
position: sticky;
top: 0;
z-index: 10;
padding: 12px 16px;
display: flex;
justify-content: flex-end;
align-items: center;
background-color: rgba(127, 127, 127, 0.05);
backdrop-filter: saturate(180%) blur(8px);
border-bottom: 1px solid var(--border-color);
}
.container {
max-width: 960px;
margin: 24px auto;
padding: 0 16px;
}
.visibleBg {
width: 500px;
height: 300px;
border: 1px solid var(--border-color);
}
</style>
</head>
<body>
<div id="app">
<div class="toolbar">
<el-switch
class="theme-switch"
:model-value="isDark"
@click="onToggleClick"
:active-icon="Moon"
:inactive-icon="Sunny"
active-text="深色"
inactive-text="浅色"
></el-switch>
</div>
<div class="container">
<el-space direction="vertical" size="large" style="width: 100%">
<el-card shadow="hover">
<template #header>
<span>示例卡片</span>
</template>
<div>当前主题:{{ isDark ? '深色' : '浅色' }}</div>
<el-divider></el-divider>
<el-space wrap>
<el-button type="primary">主要按钮</el-button>
<el-button type="success">成功按钮</el-button>
<el-button type="warning">警告按钮</el-button>
<el-button type="danger">危险按钮</el-button>
<el-button>默认按钮</el-button>
</el-space>
</el-card>
<el-alert
title="这是一条成功提示"
type="success"
show-icon
></el-alert>
<el-alert title="这是一条信息提示" type="info" show-icon></el-alert>
<el-alert
title="这是一条警告提示"
type="warning"
show-icon
></el-alert>
<el-alert title="这是一条错误提示" type="error" show-icon></el-alert>
</el-space>
</div>
<div class="visibleBg">我是来看背景色的</div>
</div>
<!-- Vue 3 & Element Plus CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.min.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script>
const { createApp, ref, onMounted, nextTick } = Vue
function setupThemeClass(isDark) {
document.documentElement.classList.toggle('dark', isDark)
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
function computeMaxRadius(x, y) {
const maxX = Math.max(x, window.innerWidth - x)
const maxY = Math.max(y, window.innerHeight - y)
return Math.hypot(maxX, maxY)
}
const app = createApp({
setup() {
const isDark = ref(false)
let tempViewStyle = null // 保存临时注入的 style 元素引用,方便清理
const initTheme = () => {
const saved = localStorage.getItem('theme')
const preferDark =
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
const next = saved ? saved === 'dark' : preferDark
isDark.value = next
setupThemeClass(next)
}
const cleanTempStyle = () => {
if (tempViewStyle && tempViewStyle.parentNode) {
tempViewStyle.parentNode.removeChild(tempViewStyle)
tempViewStyle = null
}
}
function onToggleClick(event) {
const isAppearanceTransition =
document.startViewTransition &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!isAppearanceTransition || !event) {
isDark.value = !isDark.value
// 确保在不支持 View Transition 或无事件时也同步 class 与 localStorage
setupThemeClass(isDark.value)
return
}
const x = event.clientX
const y = event.clientY
const endRadius = computeMaxRadius(x, y)
const transition = document.startViewTransition(async () => {
isDark.value = !isDark.value
setupThemeClass(isDark.value)
await nextTick()
})
console.log(endRadius, '🎈')
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: 450,
easing: 'ease-in',
pseudoElement: isDark.value
? '::view-transition-old(root)'
: '::view-transition-new(root)'
}
)
})
}
onMounted(initTheme)
const { Moon, Sunny } = ElementPlusIconsVue
return { isDark, onToggleClick, Moon, Sunny }
}
})
// 全局注册 Element Plus 与图标(可选)
Object.entries(ElementPlusIconsVue).forEach(([key, component]) => {
app.component(key, component)
})
app.use(ElementPlus)
app.mount('#app')
</script>
</body>
</html>
<style>
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
html.dark::view-transition-old(root) {
z-index: 2147483646;
}
html.dark::view-transition-new(root) {
z-index: 1;
}
input::placeholder,
textarea::placeholder {
opacity: 1;
}
</style>