1. Principle
Browsers provide the media query prefers-color-scheme to detect that the system (or user agent) prefers a "light" or "dark" theme.With CSS custom properties and a small amount of JS, it can automatically respond and support manual switching and persistence by users.
2. CSS-only
The browser will match @media (prefers-color-scheme: dark) based on system preferences.
:root {
--bg: #ffffff;
--text: #111827;
--muted: #6b7280;
}
/* 深色主题覆盖 */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1220;
--text: #e6eef6;
--muted: #9aa6b2;
}
}
/* 使用变量 */
body {
background: var(--bg);
color: var(--text);
}
a { color: var(--text); }Advantages: Simple implementation, automatically responds to system settings; Disadvantages: User manual selection cannot be persisted (nor can users be explicitly switched on the page).
3. Recommended practice: CSS variables + data-theme (supports automatic, manual and persistence)
CSS (variables and class/data-attribute)
:root {
--bg: #ffffff;
--text: #111827;
--muted: #6b7280;
color-scheme: light; /* 告诉 UA 表单控件等使用哪种配色 */
}
/* 深色主题定义(用于手动或 JS 切换) */
[data-theme="dark"] {
--bg: #0b1220;
--text: #e6eef6;
--muted: #9aa6b2;
color-scheme: dark;
}
/* 保持自动响应:如果用户系统偏好是 dark,但没有手动设置,则也应用深色变量 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg: #0b1220;
--text: #e6eef6;
--muted: #9aa6b2;
color-scheme: dark;
}
}
body {
background: var(--bg);
color: var(--text);
}Description:
data-theme="dark"used for User explicit switching when overriding system preferences.:root:not([data-theme])This sentence ensures that only when the user does not set it manually, it will automatically take effect according to the system preference (clear priority).
JS: initialization, switching, persistence, monitoring system changes
const THEME_KEY = 'site-theme';
function applyTheme(theme) {
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
} else {
document.documentElement.removeAttribute('data-theme');
}
}
// 读取持久化的用户首选项(localStorage)
function getStoredTheme() {
return localStorage.getItem(THEME_KEY); // 'dark' | 'light' | null
}
// 写入
function storeTheme(theme) {
if (theme) localStorage.setItem(THEME_KEY, theme);
else localStorage.removeItem(THEME_KEY);
}
// 初始逻辑:优先用用户设置,其次用系统偏好
function initTheme() {
const stored = getStoredTheme();
if (stored) {
applyTheme(stored);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
// 如果不想写 data-theme,使用 :root:not([data-theme]) 的 CSS 也能处理
// 这里可以不做任何事(由 CSS 的 media query 控制),或者显式设置 null
applyTheme(null);
}
}
initTheme();
// 切换接口(可绑定到按钮)
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
storeTheme(next);
}
// 监听系统主题变化(用于未手动设置时自动切换)
if (window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', e => {
const stored = getStoredTheme();
if (stored) return; // 用户已手动选择,忽略系统变化
if (e.matches) {
applyTheme(null); // 让 CSS media query 生效(或显式 applyTheme('dark'))
} else {
applyTheme(null);
}
});
}4. Avoid flickering (FOUC/jitter) - server-side rendering/initial rendering attention
When the page first loads, if the CSS or JS is slow, the wrong theme may be rendered before switching.The common practice is to <head> put a short section sync Script, write data-theme as early as possible document.documentElement , the theme is determined before the CSS is loaded:
<script>
(function(){
try {
const theme = localStorage.getItem('site-theme');
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
// 不写 data-theme 则由 CSS media query 控制;如果想显式,也可以 setAttribute('data-theme','dark')
// document.documentElement.setAttribute('data-theme','dark');
}
} catch(e){}
})();
</script>Putting this inline before CSS <link> can avoid the "white background first and then dark" flicker.
5. Notes on expansion and compatibility
color-scheme: Settingcolor-scheme: darkon root or [data-theme] will help the browser render consistent colors for system components such as form controls, scroll bars, and placeholders.image/svg/icon:
- Use an image that supports dual themes:
<picture>orimage-set(), or switchbackground-imagebased on[data-theme="dark"]in CSS. - SVG is filled with
currentColorto facilitate switching with text color.
- Use an image that supports dual themes:
Third-party components: Check whether the component library supports theme switching (Element Plus, Ant Design Vue, etc. usually have theme mechanisms).
Browsers without
prefers-color-scheme: Compatibility can still be switched via CSS variables + JS.Accessibility: Ensure that the contrast in dark mode is sufficient (WCAG guidance) so that images and icons are still visible in dark colors.
Transition/Animation: It is not recommended to make a long transition to the background color when switching themes to avoid flickering or redrawing burden.You can fade in and out for a short time, but do not affect the first screen rendering.
6. Refine your answers
"We use
@media (prefers-color-scheme: dark)to make the page follow the system theme by default, and use CSS variables and[data-theme]to support users to manually switch and persist to localStorage. When initializing the page, use inline scripts to setdata-themefirst to prevent theme flickering, and listen forchangeofmatchMedia('(prefers-color-scheme: dark)')Events synchronize system changes when not manually set by the user.Supplementary use ofcolor-scheme, SVG'scurrentColorand dual-image strategies ensure visual consistency and accessibility."
7. Application in vue3+Composition API
Vue 3 + Composition API implementation that can be directly copied and used, including:
- ✅ Automatically detect system themes
- ✅ User manual switching + local persistence
- ✅ Responsive real-time updates
- ✅ Avoid flickering (SSR/first screen optimization)
1. Directory structure suggestions
src/
├─ composables/
│ └─ useTheme.ts ← 主题切换逻辑
├─ components/
│ └─ ThemeToggle.vue ← 切换按钮
├─ main.ts ← 初始化主题(防闪烁)
└─ App.vue2. useTheme.ts (core logic)
// src/composables/useTheme.ts
import { ref, watchEffect, onMounted, onBeforeUnmount } from 'vue'
const THEME_KEY = 'site-theme' // localStorage 键名
type ThemeType = 'light' | 'dark' | null
export function useTheme() {
const theme = ref<ThemeType>(null) // 当前主题
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
/** 应用到 HTML 根节点 */
const applyTheme = (t: ThemeType) => {
const el = document.documentElement
if (t === 'dark') {
el.setAttribute('data-theme', 'dark')
el.style.colorScheme = 'dark'
} else if (t === 'light') {
el.setAttribute('data-theme', 'light')
el.style.colorScheme = 'light'
} else {
el.removeAttribute('data-theme')
el.style.colorScheme = prefersDark.matches ? 'dark' : 'light'
}
}
/** 从本地读取用户设置 */
const getStoredTheme = (): ThemeType => {
const value = localStorage.getItem(THEME_KEY)
return value === 'dark' || value === 'light' ? value : null
}
/** 切换主题 */
const toggleTheme = () => {
const next = theme.value === 'dark' ? 'light' : 'dark'
theme.value = next
localStorage.setItem(THEME_KEY, next)
}
/** 恢复默认(跟随系统) */
const resetTheme = () => {
theme.value = null
localStorage.removeItem(THEME_KEY)
}
/** 初始化 */
onMounted(() => {
const stored = getStoredTheme()
theme.value = stored ?? null
applyTheme(theme.value)
// 监听系统主题变化
const handler = (e: MediaQueryListEvent) => {
if (theme.value === null) applyTheme(null) // 未手动设置时才响应系统变化
}
prefersDark.addEventListener('change', handler)
onBeforeUnmount(() => {
prefersDark.removeEventListener('change', handler)
})
})
// 响应式应用主题变化
watchEffect(() => {
applyTheme(theme.value)
})
return { theme, toggleTheme, resetTheme }
}3. ThemeToggle.vue (button component)
<template>
<button @click="toggleTheme" class="p-2 rounded-xl border border-gray-400/30 hover:bg-gray-200 dark:hover:bg-gray-700 transition">
<span v-if="theme === 'dark'">🌙 暗色模式</span>
<span v-else-if="theme === 'light'">☀️ 亮色模式</span>
<span v-else>🪄 跟随系统</span>
</button>
<button @click="resetTheme" class="ml-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">重置</button>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { theme, toggleTheme, resetTheme } = useTheme()
</script>
<style scoped>
button {
cursor: pointer;
}
</style>4. main.ts (first screen anti-flicker initialization)
At the very top of main.ts add:
// src/main.ts
// 防止闪烁:在 Vue 应用挂载前读取 localStorage 决定主题
(() => {
try {
const theme = localStorage.getItem('site-theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const el = document.documentElement
if (theme === 'dark' || (!theme && prefersDark)) {
el.setAttribute('data-theme', 'dark')
el.style.colorScheme = 'dark'
} else {
el.setAttribute('data-theme', 'light')
el.style.colorScheme = 'light'
}
} catch {}
})()5. CSS variable definition
/* src/assets/theme.css */
:root {
--bg: #ffffff;
--text: #111827;
--muted: #6b7280;
color-scheme: light;
}
[data-theme="dark"] {
--bg: #0b1220;
--text: #e6eef6;
--muted: #9aa6b2;
color-scheme: dark;
}
body {
background: var(--bg);
color: var(--text);
transition: background-color 0.3s ease, color 0.3s ease;
}Then introduce it in main.ts or App.vue:
import './assets/theme.css'6. Usage examples
<!-- App.vue -->
<template>
<main class="min-h-screen flex flex-col items-center justify-center">
<h1 class="text-3xl mb-4">Vue3 自动响应深色模式 🌗</h1>
<ThemeToggle />
</main>
</template>
<script setup lang="ts">
import ThemeToggle from '@/components/ThemeToggle.vue'
</script>7. Bonus points for advanced levels
- ✅ Use
color-schemeto ensure that system controls (scroll bars, forms) are adaptive. - ✅ Dark/light icons are filled with
currentColoror useimage-set() - ✅ Use CSS variables + Tailwind to support dynamic themes (can be used with tailwind's
darkMode: ['class', '[data-theme="dark"]']). - ✅ Can be encapsulated into a global store (such as Pinia) synchronization state.