前端技术 · 文章

深色模式切换动画完全指南:从设计策略到跨框架实现

本文从设计策略到 API 原理、从状态架构到可访问性与兼容性,系统讲解如何用 View Transition API 实现「深色模式切换的漫射(扩散)动画」,并提供可复用的 Vanilla、React、Vue 代码示例。所有示例避免绑定具体项目,读者可直接按文中步骤手动实现。


目标与边界

  • 切换机制稳健:支持 light/dark/system 三种模式、持久化保存用户选择。

  • 样式控制统一:采用 classdata-* 的暗色策略,便于跨框架与 SSR。

  • 动画自然顺滑:支持浏览器的 View Transition API,构建圆形扩散漫射效果。

  • 无感降级:不支持 API 时仍能平滑切换并保持可用性。

  • 可访问性与性能:考虑 prefers-reduced-motion、ARIA 标签与最小化重排。

  • 易集成:附通用代码示例与工程要点,便于复制粘贴与按需扩展。


实现思路总览

  • 样式策略选择:

    • class 策略:在 <html><body> 上切换 dark 类,配合 CSS/Tailwind 的 dark:* 工具类。

    • data-theme 策略:在 <html> 上切换 data-theme="dark|light",配合 CSS 变量(推荐可维护性最高)。

  • 状态与持久化:

    • 入口初始化:读取 localStorage 与系统偏好(prefers-color-scheme),计算初始模式并应用。

    • 切换函数:更新模式 → 写入存储 → 应用到 DOM。

  • 动画实现:

    • 使用 document.startViewTransition() 包裹 DOM 变更。

    • 在过渡准备好后,对 ::view-transition-new(root) 使用 clipPath 圆形动画,从某个起点向外扩散。

  • 兼容与降级:

    • 用 feature detection 判断 API 支持。

    • prefers-reduced-motion 则关闭或缩短动画。

  • 可访问性:

    • 动态 aria-label

    • 焦点态与键盘操作友好。

  • SSR/水合一致性:

    • 入口尽早初始化,不让首帧闪屏。

    • 仅在客户端挂载后渲染切换按钮(可选)。


Step 1:准备样式与主题策略

两种常用策略:

  • class 策略(简单直接):

    • html 上添加/移除 dark 类。

    • CSS 中用 .dark ... 或 Tailwind 的 dark: 工具类定义暗色覆盖。

  • data-theme + CSS 变量策略(更灵活,适合复杂设计系统):

    • 使用 :root 定义主题变量(颜色、阴影、背景等)。

    • [data-theme="dark"] 下重定义同名变量,实现全站切换。

示例(CSS 变量策略):

/* 基础变量:默认浅色 */
:root {
  --bg: #fafafa;
  --text: #111827;
  --surface: #ffffff;
  --border: #e5e7eb;
}

/* 暗色变量覆盖 */
:root[data-theme="dark"] {
--bg: #0f172a;
--text: #e5e7eb;
--surface: #111827;
--border: #334155;
}

/* 使用变量的通用样式 */
body {
background: var(--bg);
color: var(--text);
}

.card {
background: var(--surface);
border: 1px solid var(--border);
}

/* View Transition 基础设置:移除默认动画,交给我们控制 /
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}


Step 2:基础结构与按钮

简化的 HTML 结构:

<button id="theme-toggle" aria-label="切换到深色模式">切换主题</button>
  • aria-label 将在 JS 中随模式动态更新。

  • 无框架场景下,事件绑定直接用原生 JS。


Step 3:状态与初始化

Vanilla JS 完整示例(含函数级别注释与降级逻辑):

/*
  • 读取用户主题偏好(localStorage),如无则返回 'system'
    */
    function readStoredThemeMode() {
    try {
    return localStorage.getItem('themeMode') || 'system'
    } catch {
    return 'system'
    }
    }

/**

  • 计算当前是否为暗色:

  • 'dark':暗色

  • 'light':浅色

  • 'system':跟随媒体查询 prefers-color-scheme
    */
    function computeIsDark(mode) {
    if (mode === 'dark') return true
    if (mode === 'light') return false
    const mq = window.matchMedia?.('(prefers-color-scheme: dark)')
    return mq ? mq.matches : false
    }

/**

  • 将主题应用到 DOM(data-theme 或 class 策略均可)

  • 这里使用 data-theme,易与 CSS 变量集成
    */
    function applyThemeToDOM(isDark) {
    const root = document.documentElement
    root.setAttribute('data-theme', isDark ? 'dark' : 'light')
    }

/**

  • 是否需要减少动画(可访问性优先)
    */
    function shouldReduceMotion() {
    return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
    }

/**

  • 初始化主题:

  • 读取存储 + 系统偏好,应用到 DOM

  • 设置按钮的 aria-label

  • 监听系统偏好变化(仅在 'system' 模式时)
    */
    function initTheme() {
    const mode = readStoredThemeMode()
    let isDark = computeIsDark(mode)
    applyThemeToDOM(isDark)
    updateToggleButtonAria(isDark)

if (mode === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
mq.addEventListener('change', () => {
isDark = mq.matches
applyThemeToDOM(isDark)
updateToggleButtonAria(isDark)
})
}
}

/**

  • 更新切换按钮的可访问性标签
    */
    function updateToggleButtonAria(isDark) {
    const btn = document.getElementById('theme-toggle')
    if (!btn) return
    btn.setAttribute('aria-label', isDark ? '切换到浅色模式' : '切换到深色模式')
    }

/**

  • 写入用户选择到 localStorage
    */
    function persistThemeMode(mode) {
    try {
    localStorage.setItem('themeMode', mode)
    } catch {}
    }

/**

  • 执行带漫射动画的主题切换

  • 使用 View Transition API 包裹 DOM 变更

  • 在新视图伪元素上通过 clipPath 实现圆形扩散

  • 不支持或减少动画时走降级路径
    */
    async function toggleThemeWithDiffuse(event) {
    const currentIsDark = document.documentElement.getAttribute('data-theme') === 'dark'
    const nextMode = currentIsDark ? 'light' : 'dark'

// 持久化用户选择
persistThemeMode(nextMode)

// 降级:不支持或要求减少动画时,直接切换
if (!document.startViewTransition || shouldReduceMotion()) {
applyThemeToDOM(nextMode === 'dark')
updateToggleButtonAria(nextMode === 'dark')
return
}

// 计算扩散起点:用户点击位置(如无 event 则右上角)
const x = event?.clientX ?? window.innerWidth
const y = event?.clientY ?? 0
const endRadius = Math.hypot(window.innerWidth, window.innerHeight)

// 在过渡中执行 DOM 变更
const transition = document.startViewTransition(async () => {
applyThemeToDOM(nextMode === 'dark')
})

// 等待新视图伪元素就绪
await transition.ready

// 对新视图伪元素进行圆形扩散动画
document.documentElement.animate(
{
clipPath: [
circle(0px at ${x}px ${y}px),
circle(${endRadius}px at ${x}px ${y}px)
]
},
{
duration: 900,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
pseudoElement: '::view-transition-new(root)'
}
)

// 更新按钮文案
updateToggleButtonAria(nextMode === 'dark')
}

/**

  • 绑定事件与启动初始化
    */
    function bootstrapThemeToggle() {
    initTheme()
    const btn = document.getElementById('theme-toggle')
    if (!btn) return
    btn.addEventListener('click', toggleThemeWithDiffuse)
    }

// 启动
bootstrapThemeToggle()

要点:

  • document.startViewTransition 是 Chrome/Safari 新版支持的 API,用伪元素承载新旧视图,利于做遮罩/漫射动画。

  • clipPath: circle(...) 是实现扩散遮罩的关键,半径用 Math.hypot 覆盖可视区域。

  • prefers-reduced-motion 兼顾可访问性,尊重用户减少动画偏好。

  • 事件坐标作为动画起点,体验更自然可感。


Step 4:Tailwind 的 class 暗色策略

如用 Tailwind,可设置 darkMode: 'class',然后在 html 切换 class="dark"

/**
  • 切换暗色类(Tailwind 策略)
    */
    function applyTailwindDarkClass(isDark) {
    const root = document.documentElement
    root.classList.toggle('dark', isDark)
    }

在组件内使用 dark:bg-gray-900 dark:text-gray-100 即可完成暗色适配。
动画逻辑与上文一致,唯一差异是应用主题的方法从 data-theme 改为 classList.toggle('dark', ...)


Step 5:React 与 Vue 集成示例

React(函数组件)

import { useEffect, useState } from 'react'

/**

  • 计算是否减少动画(可访问性)
    */
    function shouldReduceMotion() {
    return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
    }

/**

  • 将主题应用到 DOM(data-theme 策略)
    */
    function applyThemeToDOM(isDark) {
    document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light')
    }

/**

  • 读取模式并计算当前是否暗色
    */
    function getInitialDark() {
    const stored = localStorage.getItem('themeMode') || 'system'
    if (stored === 'dark') return true
    if (stored === 'light') return false
    return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false
    }

/**

  • React 主题切换按钮:带漫射动画
    */
    export default function ThemeToggle() {
    const [isDark, setIsDark] = useState(getInitialDark())

useEffect(() => {
applyThemeToDOM(isDark)
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const listener = () => {
const mode = localStorage.getItem('themeMode') || 'system'
if (mode === 'system') {
const next = mq.matches
setIsDark(next)
applyThemeToDOM(next)
}
}
mq.addEventListener('change', listener)
return () => mq.removeEventListener('change', listener)
}, [])

/**

  • 切换主题:支持 View Transition 漫射动画
    */
    async function toggleTheme(e) {
    const nextMode = isDark ? 'light' : 'dark'
    localStorage.setItem('themeMode', nextMode)

if (!document.startViewTransition || shouldReduceMotion()) {
  const next = nextMode === 'dark'
  setIsDark(next)
  applyThemeToDOM(next)
  return
}

const x = e?.clientX ?? window.innerWidth
const y = e?.clientY ?? 0
const endRadius = Math.hypot(window.innerWidth, window.innerHeight)

const transition = document.startViewTransition(async () =&gt; {
  const next = nextMode === 'dark'
  setIsDark(next)
  applyThemeToDOM(next)
})
await transition.ready
document.documentElement.animate(
  {
    clipPath: [
      `circle(0px at ${x}px ${y}px)`,
      `circle(${endRadius}px at ${x}px ${y}px)`
    ]
  },
  {
    duration: 900,
    easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
    pseudoElement: '::view-transition-new(root)'
  }
)

}

return (
<button
onClick={toggleTheme}
aria-label={isDark ? '切换到浅色模式' : '切换到深色模式'}
>
{isDark ? '🌙' : '☀️'}
</button>
)
}

Vue(组合式 API)

<script setup>
import { ref, onMounted } from 'vue'

/**

  • 是否减少动画(可访问性)
    */
    function shouldReduceMotion() {
    return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches
    }

/**

  • 应用主题到 DOM(data-theme 策略)
    */
    function applyThemeToDOM(isDark) {
    document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light')
    }

/**

  • 读取并计算初始暗色
    */
    function getInitialDark() {
    const stored = localStorage.getItem('themeMode') || 'system'
    if (stored === 'dark') return true
    if (stored === 'light') return false
    return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false
    }

const mounted = ref(false)
const isDark = ref(getInitialDark())

/**

  • 初始化:应用主题、绑定系统偏好监听
    */
    function initTheme() {
    applyThemeToDOM(isDark.value)
    const mq = window.matchMedia('(prefers-color-scheme: dark)')
    const listener = () => {
    const mode = localStorage.getItem('themeMode') || 'system'
    if (mode === 'system') {
    isDark.value = mq.matches
    applyThemeToDOM(isDark.value)
    }
    }
    mq.addEventListener('change', listener)
    }

/**

  • 切换主题:带 View Transition 漫射动画
    */
    async function toggleTheme(event) {
    const nextMode = isDark.value ? 'light' : 'dark'
    localStorage.setItem('themeMode', nextMode)

if (!document.startViewTransition || shouldReduceMotion()) {
isDark.value = nextMode === 'dark'
applyThemeToDOM(isDark.value)
return
}

const x = event?.clientX ?? window.innerWidth
const y = event?.clientY ?? 0
const endRadius = Math.hypot(window.innerWidth, window.innerHeight)

const transition = document.startViewTransition(async () => {
isDark.value = nextMode === 'dark'
applyThemeToDOM(isDark.value)
})
await transition.ready
document.documentElement.animate(
{
clipPath: [
circle(0px at ${x}px ${y}px),
circle(${endRadius}px at ${x}px ${y}px)
]
},
{
duration: 900,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
pseudoElement: '::view-transition-new(root)'
}
)
}

onMounted(() => {
initTheme()
mounted.value = true
})
</script>

<template>
<button
v-if="mounted"
@click="toggleTheme"
:aria-label="isDark ? '切换到浅色模式' : '切换到深色模式'"
>
<span v-if="isDark">🌙</span>
<span v-else>☀️</span>
</button>
</template>


Step 6:细节优化与工程建议

  • 动画起点策略:

    • 跟随点击位置更自然;统一右上角/右下角更具设计感。

  • 缓动与时长:

    • 推荐 900ms + cubic-bezier(0.22, 1, 0.36, 1),高级丝滑。

  • SSR 与水合:

    • 入口尽早应用主题,避免首次渲染闪屏。

    • 切换按钮在 mounted 后再显示可减少图标错位。

  • 持久化与系统偏好:

    • 用户显式选择优先于系统偏好;system 模式下挂监听。

  • 可访问性:

    • prefers-reduced-motion 时禁用或缩短动画。

    • 动态 aria-label,键盘可操作性(Enter/Space)与 focus-visible

  • 性能:

    • 避免在切换时触发大量重排;将主题切换集中到根节点以减少影响面。

  • 安全与隐私:

    • 本地存储主题选择不包含敏感信息;代码不应上报或持久化到远端。


常见问题与排错

  • 切换不生效:

    • 确认根节点是否应用了 data-themedark 类。

  • 动画不执行:

    • 浏览器未支持 View Transition API 或用户开启减少动画;确认降级路径执行。

  • 首帧闪屏:

    • 初始化时机太晚;将 initTheme() 放在应用启动最早的时刻。

  • 样式不一致:

    • 混用 CSS 变量与类覆盖时要统一策略;建议变量作为底层,类做少量覆盖。


总结

通过「主题状态(light/dark/system) + 根节点切换(data-theme 或 class) + View Transition API + clipPath 漫射动画 + 降级与可访问性」的组合方案,可以稳健地实现现代化的深色模式切换动画。该方案框架无关,既可用于纯 Vanilla,也可在 React/Vue 等框架中快速落地,并具备良好的扩展性与工程可维护性。

将本文示例直接复制到你的项目中,按需选择 data-theme(CSS 变量)或 class(Tailwind)策略,再接入 View Transition 的漫射动画,即可实现专业且舒适的主题切换体验。

版权声明

本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。

本文链接:https://xuyi.dev/2026-04-24-e01dir