深色模式切换的漫射动画:原理、架构与落地实现

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

分类:前端技术

标签:Vue.js, 前端, 教程笔记

发布时间:2025-12-21T21:25:22


深色模式切换的漫射动画:原理、架构与落地实现

本文从设计策略到 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 () => {
      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 的漫射动画,即可实现专业且舒适的主题切换体验。