前端主题切换设计
主题切换不仅是增加一个「深色模式」按钮。它还要处理设计 token、运行时状态、系统主题、持久化、首屏渲染和业务组件边界。
一个可维护的主题方案,至少要解决三件事:
- 语义稳定:业务组件不直接依赖具体颜色值,而是依赖语义 token;
- 切换可靠:用户选择、系统主题变化、刷新页面和首屏渲染都能得到一致结果;
- 成本可控:主题能力集中在基础设施层,避免散落到每个组件。
方案设计
主题系统可以拆成四层:
主题状态应区分用户偏好和最终生效主题。只使用 light | dark 无法表示「跟随系统」这一选项,推荐拆成两个类型:
type ThemeMode = 'light' | 'dark'
type ThemePreference = ThemeMode | 'system'
ThemePreference 表示用户选择的主题偏好,负责持久化,取值可以是 light、dark 或 system;
ThemeMode 表示最终生效的主题,始终是 light 或 dark,负责驱动渲染。
选择 system 时,根据系统主题解析出 ThemeMode;选择 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 暴露系统主题偏好,matchMedia 可以读取当前值:
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const isDark = mediaQuery.matches
当用户选择「跟随系统」时,还需要监听 mediaQuery 的 change 事件。下面只展示监听逻辑,preference 来自 ThemeProvider 的状态,applyTheme 负责写入最终主题,完整实现见后文:
const handleSystemThemeChange = () => {
if (preference !== 'system') return
applyTheme(isDark ? 'dark' : 'light')
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
系统主题变化只影响 preference === 'system' 的用户。用户手动选择 dark 或 light 后,系统主题变化不会覆盖该选择。
首屏闪屏
如果初始主题写入过晚,页面会先按默认主题绘制,再切换到用户主题,产生短暂闪烁。解决方式是在应用启动前确定并写入初始主题。
常见做法有两种:
- 浏览器端渲染或静态站点:在
<head> 放置内联脚本,提前写入 data-theme;
- SSR:把主题偏好同步到 cookie,由服务端输出初始
<html data-theme="...">。
浏览器端实现
前面讨论的是通用设计思路,下面以 React 应用为例给出一套完整方案,分为四部分:
<head> 内联脚本,解决首屏闪屏;
- 全局 CSS 变量,承载主题视觉差异;
- React
ThemeProvider,管理运行时主题状态;
- 主题切换与业务组件,业务组件通过 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 主要用于确定首屏主题。服务端无法访问浏览器的 localStorage 和 matchMedia,所以客户端需要把用户偏好同步到 cookie,服务端再根据 cookie 将初始主题写入 HTML:
<html data-theme="dark"></html>
用户选择 light 或 dark 时,服务端可以直接输出对应主题。选择 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 变量负责视觉差异,首屏脚本或服务端输出负责在页面绘制前确定初始主题。