Skip to content
Created: March 31, 2020

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.

css
: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) ​

css
: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 ​

js
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:

html
<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: Setting color-scheme: dark on 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> or image-set(), or switch background-image based on [data-theme="dark"] in CSS.
    • SVG is filled with currentColor to facilitate switching with text color.
  • 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 set data-theme first to prevent theme flickering, and listen for change of matchMedia('(prefers-color-scheme: dark)')Events synchronize system changes when not manually set by the user.Supplementary use of color-scheme, SVG's currentColor and 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.vue

2. useTheme.ts (core logic) ​

ts
// 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) ​

vue
<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:

ts
// 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 ​

css
/* 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:

ts
import './assets/theme.css'

6. Usage examples ​

vue
<!-- 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-scheme to ensure that system controls (scroll bars, forms) are adaptive.
  • ✅ Dark/light icons are filled with currentColor or use image-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.