前端主题切换设计

主题切换不仅是增加一个「深色模式」按钮。它还要处理设计 token、运行时状态、系统主题、持久化、首屏渲染和业务组件边界。

一个可维护的主题方案,至少要解决三件事:

  • 语义稳定:业务组件不直接依赖具体颜色值,而是依赖语义 token;
  • 切换可靠:用户选择、系统主题变化、刷新页面和首屏渲染都能得到一致结果;
  • 成本可控:主题能力集中在基础设施层,避免散落到每个组件。

方案设计

主题系统可以拆成四层:

层级职责示例
token 层定义语义变量,不关心运行时逻辑--color-bg-page--color-text-primary
主题声明层为不同主题提供变量值lightdark
主题运行时层解析当前主题、监听变化、写入 DOMdata-themelocalStoragematchMedia
业务组件层只消费语义变量background: var(--color-bg-page)

主题状态应区分用户偏好和最终生效主题。只使用 light | dark 无法表示「跟随系统」这一选项,推荐拆成两个类型:

type ThemeMode = 'light' | 'dark'
type ThemePreference = ThemeMode | 'system'
  • ThemePreference 表示用户选择的主题偏好,负责持久化,取值可以是 lightdarksystem
  • ThemeMode 表示最终生效的主题,始终是 lightdark,负责驱动渲染。

选择 system 时,根据系统主题解析出 ThemeMode;选择 lightdark 时,直接使用对应值,不受系统主题变化影响。

切换主题

主题切换的核心动作是把最终生效主题写到 <html> 上:

document.documentElement.dataset.theme = 'dark'

执行后页面会变成:

<html data-theme="dark"></html>

这行代码本身不直接改颜色,而是改变 CSS 选择器的命中结果:

[data-theme='light'] {
  --color-bg-page: #f8fafc;
  --color-text-primary: #111827;
}

[data-theme='dark'] {
  --color-bg-page: #0f172a;
  --color-text-primary: #f9fafb;
}

组件只消费变量:

.page {
  background: var(--color-bg-page);
  color: var(--color-text-primary);
}

完整链路如下:

  1. 用户选择主题;
  2. 运行时解析出最终主题;
  3. JS 写入 <html data-theme="...">
  4. 对应主题的 CSS 变量生效;
  5. 使用这些变量的组件自动更新样式。

所以主题切换的本质是:JS 只切换主题标识,CSS 变量承载具体视觉差异

适应系统主题

浏览器通过 prefers-color-scheme 暴露系统主题偏好,matchMedia 可以读取当前值:

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const isDark = mediaQuery.matches

当用户选择「跟随系统」时,还需要监听 mediaQuerychange 事件。下面只展示监听逻辑,preference 来自 ThemeProvider 的状态,applyTheme 负责写入最终主题,完整实现见后文:

const handleSystemThemeChange = () => {
  if (preference !== 'system') return
  applyTheme(isDark ? 'dark' : 'light')
}

mediaQuery.addEventListener('change', handleSystemThemeChange)

系统主题变化只影响 preference === 'system' 的用户。用户手动选择 darklight 后,系统主题变化不会覆盖该选择。

首屏闪屏

如果初始主题写入过晚,页面会先按默认主题绘制,再切换到用户主题,产生短暂闪烁。解决方式是在应用启动前确定并写入初始主题。

常见做法有两种:

  • 浏览器端渲染或静态站点:在 <head> 放置内联脚本,提前写入 data-theme
  • SSR:把主题偏好同步到 cookie,由服务端输出初始 <html data-theme="...">

浏览器端实现

前面讨论的是通用设计思路,下面以 React 应用为例给出一套完整方案,分为四部分:

  1. <head> 内联脚本,解决首屏闪屏;
  2. 全局 CSS 变量,承载主题视觉差异;
  3. React ThemeProvider,管理运行时主题状态;
  4. 主题切换与业务组件,业务组件通过 CSS 变量自动应用主题。

写入初始主题

这段脚本应放在 CSS 和 React 应用脚本之前,确保首个绘制前已经写入主题。

<script>
  ;(() => {
    const key = 'theme-preference'
    const value = localStorage.getItem(key)
    const themes = ['light', 'dark', 'system']
    const preference = themes.includes(value) ? value : 'system'
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    const theme = preference === 'system' ? (isDark ? 'dark' : 'light') : preference

    document.documentElement.dataset.theme = theme
  })()
</script>

它在 React 启动前把解析后的主题写到 <html> 上。

定义主题变量

主题变量统一放在全局 CSS 中:

:root,
[data-theme='light'] {
  color-scheme: light;

  --color-bg-page: #f8fafc;
  --color-bg-surface: #ffffff;
  --color-text-primary: #111827;
  --color-text-secondary: #4b5563;
  --color-border-subtle: #e5e7eb;
  --color-brand-primary: #2563eb;
}

[data-theme='dark'] {
  color-scheme: dark;

  --color-bg-page: #0f172a;
  --color-bg-surface: #111827;
  --color-text-primary: #f9fafb;
  --color-text-secondary: #cbd5e1;
  --color-border-subtle: #334155;
  --color-brand-primary: #60a5fa;
}

color-scheme 用来告诉浏览器当前页面适合使用哪种原生配色。它会影响表单控件、滚动条等浏览器默认 UI 的明暗表现;业务组件的颜色仍然由后面的 CSS 变量控制。

封装 ThemeProvider

ThemeProvider 负责读取用户偏好、切换主题、监听系统主题变化,并把最终主题同步到 DOM;业务组件只消费 CSS 变量,无需在 JSX 中判断当前是浅色还是深色。

import type { ReactNode } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'

type ThemeMode = 'light' | 'dark'
type ThemePreference = ThemeMode | 'system'

type ThemeContextValue = {
  preference: ThemePreference
  resolvedTheme: ThemeMode
  setPreference: (preference: ThemePreference) => void
}

type ThemeProviderProps = {
  children: ReactNode
}

const THEME_STORAGE_KEY = 'theme-preference'
const ThemeContext = createContext<ThemeContextValue | null>(null)
const themePreferences: ThemePreference[] = ['light', 'dark', 'system']

export function isThemePreference(value: unknown): value is ThemePreference {
  return typeof value === 'string' && themePreferences.includes(value as ThemePreference)
}

function getSystemTheme(): ThemeMode {
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

function resolveTheme(preference: ThemePreference): ThemeMode {
  return preference === 'system' ? getSystemTheme() : preference
}

function readThemePreference(): ThemePreference {
  const value = localStorage.getItem(THEME_STORAGE_KEY)

  if (isThemePreference(value)) {
    return value
  }

  return 'system'
}

function applyTheme(theme: ThemeMode) {
  document.documentElement.dataset.theme = theme
}

export const ThemeProvider = (props: ThemeProviderProps) => {
  const { children } = props
  const [preference, setPreferenceState] = useState<ThemePreference>(readThemePreference)
  const [resolvedTheme, setResolvedTheme] = useState<ThemeMode>(() => resolveTheme(preference))

  const setPreference = useCallback((nextPreference: ThemePreference) => {
    localStorage.setItem(THEME_STORAGE_KEY, nextPreference)
    setPreferenceState(nextPreference)
  }, [])

  useEffect(() => {
    const nextTheme = resolveTheme(preference)

    setResolvedTheme(nextTheme)
    applyTheme(nextTheme)
  }, [preference])

  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')

    const onChange = () => {
      if (preference !== 'system') return
      const nextTheme = getSystemTheme()
      setResolvedTheme(nextTheme)
      applyTheme(nextTheme)
    }

    mediaQuery.addEventListener('change', onChange)

    return () => {
      mediaQuery.removeEventListener('change', onChange)
    }
  }, [preference])

  const value = useMemo<ThemeContextValue>(
    () => ({
      preference,
      resolvedTheme,
      setPreference,
    }),
    [preference, resolvedTheme, setPreference]
  )

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

ThemeProvider.displayName = 'ThemeProvider'

export function useTheme() {
  const context = useContext(ThemeContext)

  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }

  return context
}

应用入口包一层 ThemeProvider

import { ThemeProvider } from './ThemeProvider'

export const App = () => {
  return (
    <ThemeProvider>
      <Page />
    </ThemeProvider>
  )
}

App.displayName = 'App'

实现主题切换

切换控件只更新用户偏好,不直接操作 DOM。ThemeProvider 会根据偏好解析最终主题并同步到 DOM:

import type { ChangeEvent } from 'react'
import { isThemePreference, useTheme } from './ThemeProvider'

export const ThemeSwitch = () => {
  const { preference, setPreference } = useTheme()

  const onPreferenceChange = (event: ChangeEvent<HTMLSelectElement>) => {
    const nextPreference = event.target.value

    if (isThemePreference(nextPreference)) {
      setPreference(nextPreference)
    }
  }

  return (
    <select value={preference} onChange={onPreferenceChange}>
      <option value="light">浅色</option>
      <option value="dark">深色</option>
      <option value="system">跟随系统</option>
    </select>
  )
}

ThemeSwitch.displayName = 'ThemeSwitch'

普通业务组件只消费 CSS 变量:

export const Page = () => {
  return (
    <main className="page">
      <ThemeSwitch />
      <section className="card">主题内容</section>
    </main>
  )
}

Page.displayName = 'Page'
.page {
  min-height: 100vh;
  background: var(--color-bg-page);
  color: var(--color-text-primary);
}

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

SSR 实现

SSR 主要用于确定首屏主题。服务端无法访问浏览器的 localStoragematchMedia,所以客户端需要把用户偏好同步到 cookie,服务端再根据 cookie 将初始主题写入 HTML:

<html data-theme="dark"></html>

用户选择 lightdark 时,服务端可以直接输出对应主题。选择 system 时,服务端只能先使用默认主题;如果默认主题与浏览器主题不同,就复用前面的首屏内联脚本,在页面绘制前通过 matchMedia 修正主题,再由 ThemeProvider 接管后续状态。客户端切换偏好时,还需要同步更新 cookie。

SSR 只负责首屏 HTML,不替代客户端主题实现。页面没有 SSR 需求时,使用浏览器端实现会更简单。

最佳实践

控制 token 数量

主题变量应从使用场景出发,而不是照搬完整色板。

推荐:

--color-bg-page
--color-bg-surface
--color-text-primary
--color-text-secondary
--color-border-subtle
--color-state-danger

不推荐业务组件直接使用:

--blue-500
--gray-100
--slate-900

色阶变量可以存在于设计系统内部,但组件层应该消费语义变量。

避免主题逻辑分散

不要让每个组件都判断主题:

const isDark = theme === 'dark'

return <div className={isDark ? 'dark-card' : 'light-card'} />

组件只使用统一的类名,把视觉差异交给 CSS 变量:

return <div className="card" />

明确主题切换边界

主题切换只影响视觉表达,不改变业务语义。

适合放进主题系统:

  • 颜色;
  • 阴影;
  • 图表配色;
  • 代码高亮;
  • 插图明暗版本。

不适合放进主题系统:

  • 权限逻辑;
  • 数据结构;
  • 接口参数;
  • 页面流程。

处理图片和图表

纯 CSS 变量覆盖不了所有视觉资产。图表、图片、地图、编辑器和代码高亮通常需要单独适配。

图表可以用 resolvedTheme 生成配置:

function createChartTheme(theme: ThemeMode) {
  return {
    backgroundColor: 'transparent',
    textColor: theme === 'dark' ? '#e5e7eb' : '#111827',
    gridColor: theme === 'dark' ? '#334155' : '#e5e7eb',
  }
}

图片可以按复杂度选择方案:

  • 简单图标:使用 currentColor 或 CSS 变量;
  • 产品插图:准备浅色、深色两套资源;
  • 用户上传图片:通常不随主题变化,只保证周围容器对比度足够。

保证可访问性

深色主题不能简单地对现有颜色取反。需要单独检查文本、边框、禁用态、提示态和危险态的对比度。

需要重点检查:

  • 正文与背景的对比度;
  • 次级文本在深色背景下是否过暗;
  • 细边框在浅色和深色下是否都可见;
  • focus ring 是否明显;
  • 危险、成功、警告状态是否只依赖颜色表达。

总结

前端主题切换的核心是协调用户偏好、系统主题、首屏渲染和业务组件样式。落地时可以按职责拆开:运行时负责状态和切换,CSS 变量负责视觉差异,首屏脚本或服务端输出负责在页面绘制前确定初始主题。