TailwindCSS 暗色模式:dark: 變體、v4 切換策略與 FOUC 完整解 | TailwindCSS 完整教學

2026/08/26
TailwindCSS 暗色模式:dark: 變體、v4 切換策略與 FOUC 完整解 | TailwindCSS 完整教學

前面幾篇我們把介面練得會隨螢幕伸縮、會回應使用者的每個動作,但畫面始終停在明亮的淺色底上。現代網站幾乎都得提供暗色模式(Dark Mode),讓使用者在夜間或偏好深色時獲得舒適的閱讀體驗。這一篇 TailwindCSS 的暗色模式,要用 dark: 變體搭配三種策略(跟隨系統的 media、手動切換的 class、屬性驅動的 data-*),再帶你用 v4@custom-variant 打造一鍵切換、能記住偏好、又不會閃爍的明暗系統,讓你的介面在白天與黑夜都同樣好看。

前言

前一篇 《狀態變體》 我們讓介面「隨使用者的每個動作活起來」——hover: 顧回饋、focus-visible: 顧無障礙、peer-*group-* 顧連動、has-* 讓父層依子層反應。但你可能已經注意到:那些範例全都預設在明亮的白底上。真實世界的網站不只白天要好看,夜裡也得護眼。這就是暗色模式(Dark Mode) 要解決的核心問題:如何用同一套 class,就讓整個介面在亮色與暗色之間優雅切換,而不必為深色主題重寫一份 CSS?

先用一個生活化的類比建立心智模型。暗色模式就像室內的一組「智慧調光燈」:白天你希望明亮通透(亮色),入夜後希望柔和不刺眼(暗色);而切換的方式有三種——你可以讓燈「自動感應天色」(跟隨系統偏好)、可以「牆上裝一個手動開關」(使用者自己切換並記住)、也可以「接到既有的居家控制系統」(依 data 屬性整合)。Tailwind 的做法極其直接:在任何 utility 前面加 dark: 前綴,例如把 bg-white 寫成 bg-white dark:bg-gray-900,這條深色樣式就「只在暗色模式生效」。你不需要維護兩份樣式表,只需要在同一行標註「亮色長這樣、暗色長那樣」。

本系列以 TailwindCSS v4 為預設版本。暗色模式在 v4 有一個關鍵變化:切換策略的設定從 v3 的 tailwind.config.js(darkMode: 'class')搬進了 CSS,改用 @custom-variant dark (...) 指令來覆寫 dark 的定義。本篇你將學到:

  • dark: 變體與三種策略:media(跟隨系統,v4 預設)、class(手動切換)、data-*(屬性驅動)的運作機制與適用場景
  • v4 的 @custom-variant dark 設定:如何把預設的「跟隨系統」改成「手動 class 切換」,以及 :where() 壓優先權的用意
  • 切換 UI 與持久化:實作亮/暗切換鈕、用 localStorage 記住使用者選擇、監聽系統偏好變化
  • 避免 FOUC 與配色最佳實踐:用 inline script 消除「載入時閃白」、暗色配色的號碼配對規律與語義化 token

本系列以 TailwindCSS v4 為預設版本,範例皆可直接執行。凡涉及 v3 差異之處,我會特別標註。

核心概念

三種暗色模式策略對照表

dark: 變體本身只負責「在暗色模式下套用某樣式」,但「什麼時候算暗色模式」則由策略(strategy) 決定。Tailwind 支援三種策略,先用一張表建立全景:

策略觸發機制控制方式需要 JS?典型場景
media(v4 預設)prefers-color-scheme: dark 媒體查詢跟隨作業系統設定部落格、文件站,純跟隨系統
class祖先元素有 .dark classJavaScript 手動切換應用程式,需使用者自選、記憶偏好
data祖先元素有 [data-theme=dark] 屬性JavaScript 手動切換與既有主題屬性系統整合

三者的差異只在「怎麼判定現在是暗色」:media 看的是系統偏好(使用者作業系統的深色設定),class 看的是 DOM 上有沒有 .dark,data 看的是有沒有 data-theme="dark" 屬性。而不論用哪一種,你在 HTML 裡寫 dark:bg-gray-900 的方式完全相同——策略只決定「觸發條件」,不影響你標樣式的寫法。

那該怎麼選?一個實用的判準是問自己:「使用者需不需要自己控制主題?」 如果答案是「不需要,跟系統走就好」——這適用於絕大多數部落格、文件站、行銷官網,那就用 media,它零設定、零 JavaScript,是最省心的選擇。如果答案是「需要,要能在網站上自己切換、而且下次來還記得他的選擇」——這通常是應用程式(App)、後台儀表板、需要長時間閱讀的產品,那就用 class 搭配 JavaScript 與 localStoragedata 策略則是相對小眾的選項,主要用在「你的專案已經有一套以 data-theme 驅動的多主題系統」(例如同時要切換品牌配色與明暗),為了和既有屬性系統整合而選它。實務上,九成場景會落在 mediaclass 兩者之間,data 只是把 class 的觸發條件從「class」換成「屬性」而已,行為模式完全一致。

運作原理:dark: 只是「加了主題條件的選擇器」

理解暗色模式,只要抓住一個和上一篇一脈相承的觀念:dark: 前綴,就是幫這條 utility「加上一個主題生效條件」,本質上生成一條帶媒體查詢或祖先選擇器的 CSS 規則。

media 策略(v4 預設,零設定) 下,你只要在 CSS 引入 Tailwind:

/* 引入後,dark: 直接對應 prefers-color-scheme: dark 媒體查詢 */
@import "tailwindcss";

此時 bg-white dark:bg-gray-900 生成的 CSS 大致是:平常套 bg-white,而在 @media (prefers-color-scheme: dark) 內把背景改成 gray-900。整個過程不需要一行 JavaScript,系統切深色頁面就自動變深。

若要改成 class 策略(手動切換),在 v4 是用 @custom-variant 覆寫 dark 的定義(注意:這是 v4 與 v3 最大的差異,詳見下節):

@import "tailwindcss";

/* 覆寫 dark 變體:改為「元素本身或祖先有 .dark 時」才生效 */
@custom-variant dark (&:where(.dark, .dark *));

覆寫後,同樣的 dark:bg-gray-900 生成的選擇器就變成「當 .dark 這個祖先存在時套用」,主題由 DOM 上的 class 決定,而非系統偏好。理解了「dark: = 主題條件式選擇器」,你就能明白:切換策略,本質上就是在「更換這個條件」。

關鍵術語:v4 的 @custom-variant 與 v3 的差異

以下這幾個名詞先釐清,是本篇後半實作的基礎:

  • @custom-variant(v4):v4 的 CSS 指令,用來定義或覆寫變體。切換暗色策略就是用它覆寫內建的 dark 變體:@custom-variant dark (&:where(.dark, .dark *));。這取代了 v3 在 tailwind.config.jsdarkMode: 'class' 的做法。
  • :where(.dark, .dark *) 的用意::where() 會把選擇器的優先權(specificity)壓到 0。這很重要——它避免 dark: 樣式因為多了一層祖先選擇器而變得「太強」,反而蓋掉你其他刻意寫的覆寫。.dark * 則涵蓋 .dark 底下所有後代元素。
  • prefers-color-scheme:CSS 媒體查詢,對應使用者作業系統的深色/淺色偏好,是 media 策略的底層機制。
  • color-scheme 屬性 / <meta name="color-scheme">:告知瀏覽器當前配色方案,讓原生 UI 元件(捲軸、輸入框、下拉選單)也跟著切換深淺,避免暗色頁面裡冒出一條白色捲軸。v4 另提供 scheme-light/scheme-dark utilities 可直接以 class 宣告。
  • v3 差異對照:v3 用設定檔——darkMode: 'media'(預設)、darkMode: 'class'(手動,舊版曾用 'class',3.4 後建議 'selector')、darkMode: ['selector', '[data-theme=dark]'](data 屬性)。v4 一律改在 CSS 用 @custom-variant 表達,預設仍是 media

實作範例

理論看完,我們用一個「完整可切換的頁面」把 class 策略切換鈕、localStorage 持久化、避免 FOUC 三件事一次串起來,從啟用策略、防閃爍、切換鈕到內容配色分四步實作。以下都是完整程式碼,可直接貼進頁面觀察實際效果。

步驟一:用 @custom-variant 啟用 class 策略

要做「使用者手動切換」,第一步是把預設的 media 策略改成 class 策略。在你的主 CSS 檔加入:

/* app.css:啟用 class 策略的暗色模式 */
@import "tailwindcss";

/* 覆寫 dark 變體:當 html 或任一祖先有 .dark 時,dark: 生效 */
@custom-variant dark (&:where(.dark, .dark *));

有了這行,之後只要 JavaScript 幫 <html> 加上或移除 .dark class,整頁的 dark: 樣式就會一起切換。v3 使用者請注意:你不需要這行 @custom-variant,而是在 tailwind.config.jsdarkMode: 'selector',效果相同。

步驟二:防閃爍腳本(消除 FOUC)

在寫切換鈕之前,先解決最惱人的 FOUC(載入時閃白)。關鍵是在頁面渲染之前就決定主題,做法是在 <head> 最前面放一段同步的 inline script:

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8" />
  <!-- 讓原生捲軸、表單控件也跟著深淺切換 -->
  <meta name="color-scheme" content="light dark" />

  <!-- 防閃爍:必須是 inline、同步、放在 head 最前面 -->
  <script>
    (function () {
      const saved = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      // 有存過就依存的、沒存過就跟隨系統
      const isDark = saved === 'dark' || (!saved && prefersDark);
      if (isDark) document.documentElement.classList.add('dark');
    })();
  </script>

  <link rel="stylesheet" href="/app.css" />
</head>

這段的每個細節都有理由。它必須是 inline(直接寫在 HTML 裡)、同步(不加 defer/async)、且放在 <head> 最前面——因為只有這樣,瀏覽器才會在「繪製任何內容之前」就執行它,.dark class 在第一幀就已就位,自然不會出現「白 → 黑」的閃爍。若你把這段改成外部 JS 檔或移到頁尾,瀏覽器會先畫出亮色頁面才補上 .dark,FOUC 就回來了。判斷邏輯是:有存過偏好就依存的、沒存過就跟隨系統(prefersDark),兼顧「記憶使用者選擇」與「首訪跟隨系統」。

步驟三:切換鈕與 localStorage 持久化

主題就位後,加一顆切換鈕讓使用者手動切換,並把選擇存進 localStorage:

<!-- 切換鈕:亮色顯示月亮、暗色顯示太陽 -->
<button
  id="theme-toggle"
  aria-label="切換主題"
  class="p-2 rounded-lg border transition-colors
         border-gray-200 dark:border-gray-700
         bg-white dark:bg-gray-800
         text-gray-600 dark:text-gray-300
         hover:bg-gray-100 dark:hover:bg-gray-700"
>
  <!-- 亮色模式顯示月亮(dark:hidden 在暗色時隱藏) -->
  <svg class="size-5 dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
    <path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
  </svg>
  <!-- 暗色模式顯示太陽(hidden 平常隱藏、dark:block 暗色時顯示) -->
  <svg class="size-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
    <path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.36 6.36l-.7-.7M6.34 6.34l-.7-.7m12.72 0l.7-.7M6.34 17.66l-.7.7M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
  </svg>
</button>
// 切換邏輯:翻轉 .dark 並寫入 localStorage
const html = document.documentElement;
const toggle = document.getElementById('theme-toggle');

toggle.addEventListener('click', () => {
  const isDark = html.classList.toggle('dark');       // 切換並回傳目前狀態
  localStorage.setItem('theme', isDark ? 'dark' : 'light');  // 記住選擇
});

// 使用者「沒手動選過」時,才跟隨系統偏好即時變化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('theme')) {
    html.classList.toggle('dark', e.matches);
  }
});

這裡有三個巧思。第一,圖示切換純靠 CSS:月亮用 dark:hidden(暗色時隱藏)、太陽用 hidden dark:block(平常隱藏、暗色時才顯示),不需要 JS 換 icon。第二,classList.toggle('dark') 會回傳翻轉後的布林值,直接拿來決定寫入 'dark' 還是 'light'。第三,只有在使用者「沒手動選過」(localStorage 沒值)時,才讓系統偏好變化即時反映——一旦使用者按過切換鈕表達了明確意願,就尊重他的選擇、不再被系統偏好覆蓋。

步驟四:讓內容套用暗色配色

最後把版面元素都補上 dark: 配對,整頁就能一鍵明暗切換:

<body class="min-h-screen transition-colors
             bg-gray-50 dark:bg-gray-950
             text-gray-900 dark:text-gray-100">
  <div class="max-w-md mx-auto mt-10 p-6 rounded-2xl border shadow-sm
              bg-white dark:bg-gray-800
              border-gray-200 dark:border-gray-700">
    <h1 class="text-xl font-bold text-gray-900 dark:text-white">卡片標題</h1>
    <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
      這段描述文字在暗色模式下自動換成較淺的灰色以維持可讀性。
    </p>
    <a href="#" class="mt-3 inline-block text-blue-600 dark:text-blue-400 hover:underline">
      了解更多 →
    </a>
  </div>
</body>

留意這裡的配色號碼配對規律:亮色背景用高亮度(bg-white),暗色就換低亮度的高號碼(dark:bg-gray-800);文字則相反,亮色用高號碼深色(text-gray-900)、暗色用低號碼淺色(dark:text-gray-100)。連結這類強調色在暗色下要降一階明度(text-blue-600dark:text-blue-400),否則飽和藍在深底上會過於刺眼。整段搭配 transition-colors,切換時就有平滑的過渡而非生硬跳變。

若你覺得每個元素都手寫一長串 dark: 很累,還有一個更進階、更適合設計系統的做法:用 CSS 變數定義語義化 token,讓暗色自動切換。核心概念是先在 @theme 定義亮色預設值,再在 .dark 覆寫這些變數的值:

@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --color-background: oklch(1 0 0);      /* 亮色:white */
  --color-foreground: oklch(0.15 0 0);   /* 亮色:近黑 */
  --color-border: oklch(0.9 0 0);        /* 亮色:gray-200 */
}

/* 暗色只覆寫變數的值,不必動任何元件 */
.dark {
  --color-background: oklch(0.15 0 0);
  --color-foreground: oklch(0.95 0 0);
  --color-border: oklch(0.3 0 0);
}

有了這套語義化變數,元件就只需寫 bg-background text-foreground border-border,完全不必寫任何 dark: 前綴——因為變數的值會隨 .dark 自動改變。這麼做的好處是配色邏輯集中在一處、改主題只改變數,元件層乾淨許多;代價是需要前期規劃一套 token 命名。小型專案直接用 dark: 配對就好,但當你的暗色配對開始在數十個元件裡重複時,語義化 token 會是值得的投資。

常見錯誤與最佳實踐

五個最容易踩的暗色模式坑

坑一:防閃爍腳本用外部檔案或放頁尾,FOUC 依舊。 這是暗色模式最常見的坑——把主題判斷寫進外部 JS 或 defer 腳本,結果瀏覽器先畫亮色頁、才補上 .dark,使用者看到閃白。根治法:防閃爍腳本必須是 inline、同步、放在 <head> 最前面,確保在首次繪製前就決定主題。

<!-- ❌ 外部/defer 腳本:先畫亮色再變暗,會閃 -->
<head>
  <script src="/theme.js" defer></script>
</head>

<!-- ✅ inline 同步腳本放 head 最前面:第一幀就正確 -->
<head>
  <script>
    if (localStorage.getItem('theme') === 'dark' ||
        (!localStorage.getItem('theme') &&
         matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  </script>
</head>

坑二:在 v4 還去改 tailwind.config.jsdarkMode,結果沒生效。 從 v3 升上來的人常犯——在 v4 的設定檔寫 darkMode: 'class' 卻發現手動切換沒反應。原因:v4 已把策略設定搬進 CSS。根治法:v4 改用 @custom-variant dark (&:where(.dark, .dark *)); 寫在 CSS 裡,而不是動設定檔。

/* ❌ v4 沒有從設定檔讀 darkMode,這樣寫無效 */
/* tailwind.config.js → darkMode: 'class' */

/* ✅ v4 正確做法:在 CSS 覆寫 dark 變體 */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

坑三:覆寫 dark 時漏了 :where(),dark: 樣式優先權過高蓋掉其他覆寫。 把變體寫成 &:is(.dark, .dark *) 或直接 .dark &,少了 :where() 壓優先權,導致某些自訂樣式被 dark: 硬蓋。原因:多一層祖先選擇器會提高 specificity。根治法:用 :where(.dark, .dark *) 把優先權壓到 0,行為最可預期。

坑四:暗色只換了背景,忘了文字/邊框/強調色,對比慘不忍睹。 只寫了 dark:bg-gray-900 卻沒配 dark:text-*,深底配深字幾乎看不見。原因:暗色是「整組」配色的事,不是只換背景。根治法:背景、文字、邊框、強調色成套配對,並讓強調色在暗色降一階明度(blue-600dark:blue-400)。

<!-- ❌ 只換背景,深底配深字,對比不足 -->
<div class="bg-white dark:bg-gray-900 text-gray-900">看不清楚</div>

<!-- ✅ 背景/文字/邊框成套配對 -->
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
            border border-gray-200 dark:border-gray-700">清晰易讀</div>

坑五:忘了 color-scheme,暗色頁面冒出白色捲軸與亮色表單控件。 頁面內容都變暗了,但捲軸、<input>、下拉選單卻還是亮色,格格不入。原因:原生 UI 元件不吃 dark:,得靠 color-scheme 告知瀏覽器。根治法:加 <meta name="color-scheme" content="light dark">,或用 v4 的 scheme-light dark:scheme-dark utilities。

<!-- ✅ 讓原生捲軸、表單控件也跟著深淺切換 -->
<meta name="color-scheme" content="light dark" />
<!-- 或用 v4 utilities -->
<html class="scheme-light dark:scheme-dark">...</html>

暗色模式最佳實踐清單

  • 純跟隨系統就別寫 JS:只要「跟系統走」,v4 用預設 media 策略即可,零設定零 JS(dark: 直接對應 prefers-color-scheme)。
  • 要手動切換才改 class 策略:v4 用 @custom-variant dark (&:where(.dark, .dark *)); 覆寫,搭配切換鈕與 localStorage(v3 則在設定檔寫 darkMode: 'selector')。
  • 防閃爍腳本一律 inline 同步、放 head 最前面:在首次繪製前就把 .dark 加好,徹底消除 FOUC。
  • 覆寫變體務必用 :where():把優先權壓到 0,避免 dark: 樣式蓋掉其他自訂覆寫。
  • 配色成套配對、強調色降一階明度:背景/文字/邊框一起換,blue-600dark:blue-400,並用 transition-colors 讓切換平滑。
  • 記得 color-scheme:加 <meta name="color-scheme">scheme-* utilities,讓原生捲軸與表單控件也不出戲。
  • 考慮用語義化 token:以 CSS 變數定義 --color-background--color-foreground,在 .dark 覆寫其值,元件只寫 bg-background 就自動適應,免得每個元素都手寫一長串 dark:(需較新專案架構,適合設計系統)。
  • user-* 之於表單、prose-invert 之於文章:Markdown 內容用 dark:prose-invert 一鍵反轉排版配色,省去逐一標註。

小結

這是 TailwindCSS 完整教學 系列的第二十六篇,也為 TW-5「響應式與狀態」 章節正式收尾。上一篇 《狀態變體》 讓介面「隨使用者的每個動作活起來」;而這一篇,我們讓介面學會「隨白天黑夜切換氣氛」。回顧幾個重點:

  • dark: = 主題條件式選擇器:每個 dark: 前綴只是幫 utility 加一個「暗色才生效」的條件,策略只決定觸發方式,不改變你標樣式的寫法。
  • 三種策略:media(跟隨系統,v4 預設、零 JS)、class(手動切換,需 JS)、data-*(屬性驅動);多數內容站用 media,應用程式常用 class
  • v4 用 @custom-variant 切策略:把 v3 設定檔的 darkMode: 'class' 改寫成 CSS 的 @custom-variant dark (&:where(.dark, .dark *));,:where() 壓優先權是關鍵。
  • 切換 UI 與持久化:切換鈕翻轉 .darklocalStorage 記住選擇、matchMedia 監聽系統變化,圖示切換純靠 dark:hidden/dark:block
  • 避免 FOUC 與配色:inline 同步腳本在首次繪製前決定主題,配色成套配對、強調色降一階明度,別忘 color-scheme

掌握了暗色模式,你已經能讓同一套介面在明暗之間優雅切換。至此,我們手上累積了大量重複的 class 組合——按鈕、卡片、輸入框在每個頁面都要再寫一長串 utility,難免又臭又長、還容易不一致。下一章 TW-6「元件模式」 就要解決這個問題:下一篇 《元件抽象模式》 會帶你認識如何用 @apply、元件抽取、以及框架元件化的思維,把散落的 utility 收斂成可重用、好維護的元件,讓你的 Tailwind 專案在規模變大時依然乾淨。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →