暗黑模式与主题系统
暗黑模式已经从"锦上添花"变成了"用户期望"——所有主流操作系统和浏览器都支持暗黑偏好设置,如果你的应用只有亮色模式,在深夜打开时体验会很差。但暗黑模式的实现远比"反转颜色"复杂——SSR 防闪烁、用户偏好持久化、多主题架构、与 Tailwind / Nuxt UI 的协同,每一步都有坑。本章从底层原理到生产级实现,完整覆盖 Nuxt4 的主题系统。
1. @nuxtjs/color-mode
1.1 基础配置
Nuxt4 通过 @nuxtjs/color-mode 模块管理颜色模式:
npx nuxi module add @nuxtjs/color-mode// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/color-mode'],
colorMode: {
classSuffix: '', // 类名不加后缀:.dark 而非 .dark-mode
preference: 'system', // 默认跟随系统
fallback: 'light', // 无法检测系统偏好时的回退值
storageKey: 'nuxt-color-mode',
},
})1.2 工作原理
@nuxtjs/color-mode 做了三件事:
- 检测用户偏好:读取 Cookie(SSR)或
prefers-color-scheme媒体查询(客户端) - 设置 CSS 类:在
<html>元素上添加class="dark"或class="light" - 提供 Composable:
useColorMode()返回当前模式和切换方法
<script setup>
const colorMode = useColorMode()
function toggleDark() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
</script>
<template>
<button @click="toggleDark">
{{ colorMode.value === 'dark' ? '☀️' : '🌙' }}
</button>
</template>1.3 三种模式
| 模式 | colorMode.preference | 行为 |
|---|---|---|
| 跟随系统 | 'system' | 自动检测操作系统的暗黑模式设置 |
| 强制亮色 | 'light' | 无论系统设置如何,始终亮色 |
| 强制暗色 | 'dark' | 无论系统设置如何,始终暗色 |
推荐提供三选一的切换器,而非简单的亮/暗开关——"跟随系统"是很多用户的首选。
2. CSS 变量主题系统
2.1 基于 CSS 变量的颜色切换
暗黑模式的核心技术是 CSS 变量——同一个变量名在不同主题下映射不同的颜色值:
:root {
--color-bg: #ffffff;
--color-text: #111827;
--color-border: #e5e7eb;
--color-surface: #f9fafb;
--color-primary: #3b82f6;
}
.dark {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-border: #334155;
--color-surface: #1e293b;
--color-primary: #60a5fa;
}组件代码中始终使用语义化变量名,不关心当前是什么主题:
.card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}切换主题时,只需改变 <html> 的 class(从 light 到 dark),所有使用 CSS 变量的元素自动更新——零 JavaScript 运行时开销。
2.2 与 Tailwind v4 集成
Tailwind v4 的 @theme 定义的颜色自动生成 CSS 变量。暗黑模式使用 Tailwind 的 dark: 变体:
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<h1 class="text-primary dark:text-primary-400">标题</h1>
</div>Tailwind 的 dark: 变体默认使用 class 策略(检测 <html class="dark">),与 @nuxtjs/color-mode 完美配合。
2.3 语义化颜色命名
不要在暗黑模式中简单反转颜色。好的暗黑模式需要独立设计:
| 语义 | 亮色值 | 暗色值 | 说明 |
|---|---|---|---|
--color-bg | #ffffff | #0f172a | 不是简单反转,暗色背景偏蓝灰 |
--color-text | #111827 | #f1f5f9 | 暗色文字不用纯白,减轻眼睛疲劳 |
--color-primary | #3b82f6 | #60a5fa | 暗色模式主色调要更亮 |
--color-surface | #f9fafb | #1e293b | 卡片等浮层的背景 |
--color-muted | #6b7280 | #94a3b8 | 次要文字在暗色模式下要更亮 |
原则:
- 暗色背景偏蓝灰(
#0f172a),不用纯黑(#000000)——纯黑对比度太高,刺眼 - 暗色文字不用纯白(
#f1f5f9),减轻眼睛疲劳 - 主色调在暗色模式中调亮,保持足够的对比度
3. SSR 防闪烁
3.1 闪烁的根因
SSR 场景下暗黑模式最常见的问题是闪烁(FOUC, Flash of Unstyled Content):
1. 服务端渲染 HTML(不知道用户偏好,用默认的亮色)
2. 浏览器显示亮色页面
3. JavaScript 加载,检测到用户偏好暗色
4. 切换到暗色 → 用户看到"先亮后暗"的闪烁
这个闪烁虽然只有几百毫秒,但用户体验极差——尤其在深夜,亮色页面会"亮瞎眼"。
3.2 Cookie 方案
@nuxtjs/color-mode 默认使用 Cookie 存储用户偏好。Cookie 在 SSR 请求中随 HTTP 头发送到服务端,因此服务端可以在渲染 HTML 之前就知道用户的主题偏好:
1. 首次访问:服务端读不到 Cookie → 用默认值 + 注入检测脚本
2. 检测脚本在 <head> 中同步执行,根据系统偏好设置 <html class="dark">
3. 后续访问:Cookie 中有偏好 → 服务端直接渲染正确的主题 → 零闪烁
3.3 注入脚本
@nuxtjs/color-mode 会在 <head> 中注入一段阻塞脚本(在页面渲染前执行):
<script>
// 简化版逻辑
const stored = document.cookie.match(/nuxt-color-mode=(\w+)/)?.[1]
const preference = stored || 'system'
const resolved = preference === 'system'
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: preference
document.documentElement.classList.add(resolved)
</script>这段脚本在 CSS 和 DOM 渲染前执行,确保第一帧就是正确的主题。
3.4 为什么不用 localStorage
localStorage 无法在 SSR 阶段访问(只有浏览器有 localStorage)。如果用 localStorage 存储偏好:
- SSR 渲染时不知道用户偏好 → 用默认亮色
- 客户端 JavaScript 读取 localStorage → 切换到暗色
- 必然闪烁
Cookie 的优势在于它随 HTTP 请求发送,SSR 阶段也能读到。
4. 主题切换器
4.1 三态切换器
<!-- components/ThemeToggle.vue -->
<script setup>
const colorMode = useColorMode()
const modes = [
{ value: 'system', icon: 'i-heroicons-computer-desktop', label: '跟随系统' },
{ value: 'light', icon: 'i-heroicons-sun', label: '亮色' },
{ value: 'dark', icon: 'i-heroicons-moon', label: '暗色' },
] as const
function cycle() {
const current = modes.findIndex(m => m.value === colorMode.preference)
const next = (current + 1) % modes.length
colorMode.preference = modes[next].value
}
</script>
<template>
<button
@click="cycle"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
:title="modes.find(m => m.value === colorMode.preference)?.label"
>
<span :class="modes.find(m => m.value === colorMode.preference)?.icon" />
</button>
</template>4.2 过渡动画
主题切换时添加平滑过渡:
/* 全局过渡 */
html.color-mode-transition,
html.color-mode-transition *,
html.color-mode-transition *::before,
html.color-mode-transition *::after {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important;
}// 在切换时添加过渡类
function switchTheme(mode: string) {
document.documentElement.classList.add('color-mode-transition')
colorMode.preference = mode
setTimeout(() => {
document.documentElement.classList.remove('color-mode-transition')
}, 300)
}注意:不要全局始终应用 transition——它会影响所有颜色变化(包括 hover、focus),导致性能问题和不必要的动画。只在切换主题的瞬间临时添加。
4.3 View Transition API
现代浏览器支持 View Transition API,可以实现更酷炫的主题切换动画:
async function toggleWithTransition() {
if (!document.startViewTransition) {
// 回退:直接切换
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
return
}
await document.startViewTransition(() => {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}).ready
// 可以在这里添加自定义动画
}5. 多主题系统
5.1 超越亮/暗
有些产品需要不止两种主题——品牌主题、季节主题、用户自定义主题。CSS 变量架构天然支持多主题:
/* 主题:默认 */
:root, .theme-default {
--color-primary: #3b82f6;
--color-accent: #8b5cf6;
--color-bg: #ffffff;
}
/* 主题:暖色 */
.theme-warm {
--color-primary: #f59e0b;
--color-accent: #ef4444;
--color-bg: #fffbeb;
}
/* 主题:冷色 */
.theme-cool {
--color-primary: #06b6d4;
--color-accent: #3b82f6;
--color-bg: #ecfeff;
}
/* 每个主题都有暗色变体 */
.dark.theme-warm {
--color-primary: #fbbf24;
--color-accent: #f87171;
--color-bg: #1c1917;
}5.2 主题管理
// composables/useTheme.ts
export function useTheme() {
const colorMode = useColorMode()
const theme = useCookie('app-theme', { default: () => 'default' })
const availableThemes = ['default', 'warm', 'cool'] as const
function setTheme(name: typeof availableThemes[number]) {
// 移除旧主题类
availableThemes.forEach(t => {
document.documentElement.classList.remove(`theme-${t}`)
})
// 添加新主题类
document.documentElement.classList.add(`theme-${name}`)
theme.value = name
}
// 初始化
onMounted(() => {
if (theme.value !== 'default') {
document.documentElement.classList.add(`theme-${theme.value}`)
}
})
return {
theme: readonly(theme),
colorMode,
availableThemes,
setTheme,
}
}5.3 用户自定义主题
最灵活的方案是让用户直接自定义主色调:
function setCustomPrimary(hue: number) {
const root = document.documentElement
root.style.setProperty('--color-primary', `oklch(0.6 0.2 ${hue})`)
root.style.setProperty('--color-primary-50', `oklch(0.97 0.02 ${hue})`)
root.style.setProperty('--color-primary-500', `oklch(0.6 0.2 ${hue})`)
root.style.setProperty('--color-primary-900', `oklch(0.3 0.1 ${hue})`)
}使用 oklch 色彩空间——只需改变色相(hue),就能生成感知一致的完整色阶。
6. 暗黑模式设计注意事项
6.1 图片适配
暗色背景上的纯白图片会非常刺眼。考虑降低亮度:
.dark img:not([data-no-dim]) {
filter: brightness(0.9);
}或者为暗黑模式提供专门的图片版本:
<template>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/images/logo-dark.svg" />
<img src="/images/logo-light.svg" alt="Logo" />
</picture>
</template>6.2 阴影
亮色模式下的阴影在暗色背景上几乎不可见。暗色模式的"阴影"应该用更暗的背景或微妙的边框替代:
.card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.dark .card {
box-shadow: none;
border: 1px solid var(--color-border);
}6.3 对比度
确保文字在两种模式下都满足 WCAG AA 标准(对比度 ≥ 4.5:1)。可以用 Chrome DevTools 的 Contrast Ratio 工具检查。
本章小结
- @nuxtjs/color-mode:检测偏好 + 设置 CSS 类 + 提供 Composable,三种模式(system/light/dark)
- CSS 变量架构:语义化命名(
--color-bg/--color-text),.dark类切换变量值,零 JS 运行时开销 - 防闪烁:Cookie 存储偏好 +
<head>阻塞脚本在首帧设置正确主题,不用 localStorage - 暗色设计:背景偏蓝灰(非纯黑)、文字非纯白、主色调调亮、图片降低亮度、阴影用边框替代
- 多主题:CSS 变量 + 主题类名组合(
theme-warm+dark),oklch色彩空间实现用户自定义色调