前端主题切换设计
主题切换不是简单地加一个“深色模式”按钮。真正要处理的是一套从设计 token 到运行时状态的协作机制,还包括系统主题、持久化、首屏渲染和业务组件边界。
一个可维护的主题方案,至少要解决三件事:
- 语义稳定:业务组件不直接依赖具体颜色值,而是依赖语义 token;
- 切换可靠:用户主动选择、系统主题变化、刷新页面、首屏渲染都能得到一致结果;
- 成本可控:主题能力集中在基础设施层,不散落到每个组件里。
方案设计
主题系统可以拆成四层:
主题状态不要只设计成 light | dark,否则无法表达“跟随系统”。更推荐拆成两个概念:
type ThemeMode = 'light' | 'dark'
type ThemePreference = ThemeMode | 'system'
ThemePreference:用户偏好,负责持久化;
ThemeMode:最终生效主题,负责渲染。
当用户选择 system 时,最终主题由系统主题推导;当用户选择 light 或 dark 时,最终主题不会被系统主题覆盖。
切换主题
主题切换的核心动作是把最终生效主题写到 <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);
}
完整链路是:
- 用户选择主题。
- 运行时解析出最终主题。
- JS 写入
<html data-theme="...">。
- 对应主题的 CSS 变量生效。
- 使用这些变量的组件自动更新样式。
所以主题切换的本质是:JS 只切换主题标识,CSS 变量承载具体视觉差异。
适应系统主题
系统主题通过 prefers-color-scheme 获取:
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
当用户选择“跟随系统”时,还需要监听系统主题变化:
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemThemeChange = () => {
// 只有 preference === 'system' 时,才重新应用系统主题
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
这里要注意边界:系统主题变化只能影响 preference === 'system' 的用户;如果用户已经手动选择 dark 或 light,系统变化不应该覆盖用户选择。
首屏闪屏
如果初始主题写入得太晚,页面可能先按默认主题完成绘制,再切换到用户主题,从而出现短暂闪烁。解决首屏闪屏的关键是:在应用启动前就写入初始主题。
常见做法有两种:
- SPA 或静态站:在
<head> 放一段很小的内联脚本,提前写入 data-theme;
- SSR:把主题偏好同步到 cookie,服务端直接输出初始
<html data-theme="...">。
具体实现
前面讨论的是通用设计思路,下面以 React SPA 为例给出一套完整方案,包含四部分:
<head> 内联脚本:解决首屏闪屏;
- 全局 CSS 变量:承载主题视觉差异;
- React
ThemeProvider:管理运行时主题状态;
- 主题切换与业务组件:业务组件通过 CSS 变量自动应用主题。
1. 写入初始主题
这段脚本要放在 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 启动前,把正确的 data-theme 写到 <html> 上。
2. 定义主题变量
主题变量统一放在全局 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 变量控制。
3. 封装 ThemeProvider
在 React 应用中,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']
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'
4. 实现主题切换
切换控件只更新用户偏好,不直接操作 DOM:
import type { ChangeEvent } from 'react'
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);
}
最佳实践
1. 控制 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
色阶变量可以存在于设计系统内部,但组件层应该消费语义变量。
2. 避免主题逻辑分散
不要让每个组件都判断主题:
const isDark = theme === 'dark'
return <div className={isDark ? 'dark-card' : 'light-card'} />
更好的方式是把差异留给 CSS 变量:
return <div className="card" />
3. 明确主题切换边界
主题切换应该影响视觉表达,不应该改变业务语义。
适合放进主题系统:
- 颜色;
- 阴影;
- 图表配色;
- 代码高亮;
- 插图明暗版本。
不适合放进主题系统:
4. 处理图片和图表
纯 CSS 变量无法覆盖所有视觉资产。图表、图片、地图、编辑器和代码高亮通常需要单独适配。
图表可以用 resolvedTheme 生成配置:
function createChartTheme(theme: ThemeMode) {
return {
backgroundColor: 'transparent',
textColor: theme === 'dark' ? '#e5e7eb' : '#111827',
gridColor: theme === 'dark' ? '#334155' : '#e5e7eb',
}
}
图片可以按复杂度选择方案:
- 简单图标:使用
currentColor 或 CSS 变量;
- 产品插图:准备浅色、深色两套资源;
- 用户上传图片:通常不随主题变化,只保证周围容器对比度足够。
5. 保证可访问性
深色主题不是把颜色反过来。需要单独检查文本、边框、禁用态、提示态和危险态的对比度。
需要重点检查:
- 正文与背景的对比度;
- 次级文本在深色背景下是否过暗;
- 细边框在浅色和深色下是否都可见;
- focus ring 是否明显;
- 危险、成功、警告状态是否只依赖颜色表达。
6. 兼容 SSR
如果应用使用 SSR,服务端无法直接读取 localStorage 和 matchMedia。更稳定的做法是把用户主题偏好同步到 cookie,服务端根据 cookie 输出初始主题:
<html data-theme="dark"></html>
如果采用这种方案,客户端切换主题时也要同步更新 cookie。否则刷新后服务端仍然只能看到旧的主题偏好,首屏 HTML、CSS 和 React hydration 也就无法稳定保持同一个主题。
总结
前端主题切换的难点不在于切换几组颜色,而在于把用户偏好、系统主题、首屏渲染和业务组件样式放到同一套机制里处理。落地时可以按职责拆开:运行时负责状态和切换,CSS 变量负责视觉差异,首屏脚本或服务端输出负责在页面绘制前确定初始主题。