搜「dark mode CSS」,多半會看到 React、Tailwind 範例。若你做的是 Eleventy/純 HTML 靜態站,直接照搬常會卡在這三件事:

  1. 顏色寫死在元件上,深色模式要改一堆檔案。
  2. 重新整理時先閃白再變黑(典型的 FOUC)。
  3. 手動切換後,又跟系統偏好打架。

這篇先介紹一套可上線的通用做法(CSS 變數 + data-theme + 防閃腳本),再以本站為例,說明:

  • 為何以淺色為預設
  • JS 如何讀取系統偏好與 localStorage
  • header 按鈕如何切換
  • 切主題時的過場動畫(View Transition)怎麼加,本站又多做了什麼
  • 主題如何與 Barba 換頁共存

讀完後,你應能決定自己的站要不要在 <head> 加同步腳本防閃爍,並把同一套流程接到 layout。

先選方案:只跟系統,還是要手動切換?

需求做法要不要 JS持久化適合誰
只跟 OS 深/淺色@media (prefers-color-scheme)light-dark()不需要個人站、不想放按鈕
訪客手動切換data-theme="light|dark" + CSS 變數需要localStorage白天晚上不同環境讀文的人
兩者並用(多數站推薦)預設跟 OS;有存檔就覆寫需要localStorage內容站、教學站
站長換季換整套皮(非訪客切換)build-time 換主題變數不需要活動 landing、多品牌輸出

前三列都是訪客在瀏覽器裡看到的深淺色。最後一列是另一件事:你在建置網站時,把整套品牌色、版面一次換掉(例如活動 landing)。訪客切深淺色不必重新 build;換整套皮才要重新 build。別把兩種需求做成同一套機制。

使用 localStorage 時,實務上記住三點:

  • try/catch 包住讀寫。Safari 私密瀏覽等環境可能禁止寫入,未捕捉時整段腳本會中斷。
  • 全站固定同一個 key(例如 theme),避免 header 寫 theme、別的腳本寫 color-mode
  • 有存檔就以存檔為準;只有清掉存檔後,才回到跟系統偏好。

通用實作:語意色 token + data-theme

為什麼不要硬編 #fff#111

元件若直接寫 background: #fffcolor: #111,改深色時每個 selector 都要再寫一次。較穩的做法是:元件只寫這塊區域扮演什麼角色(頁面底、正文、弱化文字、框線),真正的色碼集中放在變數裡。這就是 design token:名稱描述用途,不描述「現在是白色」。

切到深色時,只要覆寫這批變數,按鈕、文章、側欄會一起變,不必維護兩份完整 CSS。

color-scheme: light dark 則是告訴瀏覽器:這個文件同時支援深淺色,請讓原生捲軸、<select>、表單控件跟著走,而不只是你自己畫的背景。

:root {
  color-scheme: light dark; /* 捲軸、表單原生控件跟著深淺色 */
  --bg: #fafafa;
  --text: #1a1a1a;
  --muted: #666;
  --border: #e5e5e5;
  --link: #2563eb;
}

:root[data-theme="dark"] {
  --bg: #0f1115;
  --text: #f3f4f6;
  --muted: #9ca3af;
  --border: #2a2f3a;
  --link: #60a5fa;
}

/* 還沒手動設 data-theme 時,跟 OS */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --bg: #0f1115;
    --text: #f3f4f6;
    --muted: #9ca3af;
    --border: #2a2f3a;
    --link: #60a5fa;
  }
}

body {
  background: var(--bg);
  color: var(--text);
}

:root:not([data-theme]) 的意思是:只有 <html>還沒有 data-theme 時,才用系統深色。一旦 JS 寫了 data-theme="light""dark",這段 @media 就不再插手,才不會跟使用者的手動選擇打架。

防 FOUC:<head> 同步腳本(通用推薦)

這裡的 FOUC(Flash of Unstyled Content)不是指「CSS 還沒載入」,而是第一幀的顏色跟最終主題不一致。典型順序:

  1. CSS 先依 @media (prefers-color-scheme: dark) 畫出深色。
  2. 外部 JS 稍後才讀到 localStorage 裡存的 light
  3. 再把畫面改回淺色。使用者會看到閃一下。

解法:在 stylesheet 之前放一段同步 inline script,在瀏覽器畫第一幀前就寫好 data-theme。不要用 deferasync,那兩種都會晚跑,趕不上第一幀。

<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<script>
  (function () {
    try {
      var stored = localStorage.getItem('theme');
      var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      var theme = stored || (prefersDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    } catch (e) {
      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        document.documentElement.setAttribute('data-theme', 'dark');
      }
    }
  })();
</script>
<link rel="stylesheet" href="/assets/css/main.css">

這段只負責「第一幀顏色對不對」,越短越好。按鈕點擊、icon、系統偏好監聽屬於互動,另放外部 theme.js(Eleventy 可放進 base.njk<head></body> 前)。不要把整包切換邏輯塞進每個頁面的 HTML。

切換按鈕 + 持久化(精簡版)

<button type="button" id="theme-toggle" aria-pressed="false" aria-label="切換深淺色模式">
  切換主題
</button>
(function () {
  var KEY = 'theme';
  var root = document.documentElement;
  var btn = document.getElementById('theme-toggle');

  function isDark() {
    return root.getAttribute('data-theme') === 'dark';
  }

  function syncButton() {
    if (!btn) return;
    btn.setAttribute('aria-pressed', String(isDark()));
    btn.setAttribute('aria-label', isDark() ? '切換到淺色模式' : '切換到深色模式');
  }

  syncButton();

  btn?.addEventListener('click', function () {
    var next = isDark() ? 'light' : 'dark';
    root.setAttribute('data-theme', next);
    try { localStorage.setItem(KEY, next); } catch (e) {}
    syncButton();
  });

  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
    try {
      if (!localStorage.getItem(KEY)) {
        root.setAttribute(
          'data-theme',
          window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
        );
        syncButton();
      }
    } catch (e) {}
  });
})();

重點在最後那段 change 監聽:使用者從未按過按鈕localStorage 沒有 key)時,半夜把系統改成深色,頁面會跟著變。一旦按過並寫入存檔,就以使用者選擇為準,系統再怎麼改也不覆寫。

只要跟系統、不要按鈕?

CSS 的 light-dark() 可少寫一段 @media(需搭配 color-scheme: light dark):

:root {
  color-scheme: light dark;
  --text: light-dark(#1a1a1a, #f3f4f6);
  --bg: light-dark(#fafafa, #0f1115);
}

light-dark(淺色, 深色) 只看元素的 color-scheme(通常跟作業系統),沒有「使用者按了按鈕」這個狀態。若還要手動切換,仍建議用 data-theme 覆寫變數;舊版 Safari 也不支援此函式,請保留 @media fallback。

切換時要不要加過場?

顏色能切、能記住之後,才考慮動畫。沒有動畫,深淺色一樣能用。

瀏覽器有一個現成的過場 API,叫 View Transition。點主題鈕時,它會:

  1. 先拍一張「現在」的畫面
  2. 等你改完 data-theme
  3. 再拍「之後」的畫面,兩張交叉淡入

主題鈕的最小寫法:把「改顏色」包進 startViewTransition。使用者開了「減少動態」,或瀏覽器沒這個 API,就直接改顏色。

function applyTheme(next) {
  document.documentElement.setAttribute('data-theme', next);
  try { localStorage.setItem('theme', next); } catch (e) {}
}

btn.addEventListener('click', function () {
  var next = isDark() ? 'light' : 'dark';
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  if (reduce || typeof document.startViewTransition !== 'function') {
    applyTheme(next);
    return;
  }

  document.startViewTransition(function () {
    applyTheme(next);
  });
});

預設淡入大約 0.25 秒。想慢一點,改 CSS 即可:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.35s;
}

本站怎麼做:淺色預設 + 系統/手動覆寫

以下對照 本站 的實際做法(本地專案請以 _includes/layouts/base.njksrc/assets/js/site.js 為準)。

第一次進站的判斷順序:

  1. localStoragetheme 有值 → 用存檔。
  2. 沒有存檔 → 依 prefers-color-scheme 決定 dark 或 light。

同一套判斷出現兩次,分工不同:<head> 的 inline script 趕在第一幀前寫上 data-themesite.js 稍後再綁按鈕、更新 icon,並在系統偏好改變時決定要不要跟著切。

flowchart TD
  A[第一次進站] --> B{localStorage.theme 有值?}
  B -->|有| C[套用已存主題]
  B -->|無| D[依 prefers-color-scheme 決定]
  C --> E[設定 html data-theme]
  D --> E
  E --> F[CSS token 套用顏色]
  G[點 #theme-toggle] --> H{prefers-reduced-motion?}
  H -->|是| I[直接切換]
  H -->|否| J[播過場動畫並切換]
  I --> K[更新 data-theme 並寫入 localStorage]
  J --> K
  K --> F
層級負責什麼本站做法
HTML掛狀態<html> 上的 data-theme="dark""light"
CSS顏色:root 定義淺色 token;[data-theme="dark"] 覆寫深色;表格、TOC、code 等再個別微調
JS讀偏好、切換、持久化site.js 讀寫 localStorage key theme,並監聽 matchMedia

CSS:淺色寫在 :root,深色靠 attribute

本站品牌是暖色淺底,所以沒有任何覆寫時,畫面必須是淺色。同時還要:沒按過按鈕時跟系統、按過之後記住。這三條路共用同一批 CSS 變數,不必維護兩份完整主題。

三層選擇器各管一件事:

  1. :root 放淺色:JS 還沒跑、attribute 也還沒設時的底線。全站元件只寫 var(--text-color),不必每個區塊都寫深/淺兩套。
  2. [data-theme="dark"] 覆寫變數:JS 只要 setAttribute("data-theme", "dark"),深色 token 就生效。這也讓使用者能在系統是深色時,強制選淺色。
  3. @media 當備援:本站用 :root:not([data-theme="light"]),意思是「只要不是明確鎖定淺色,系統深色就套深色 token」。這是給腳本沒跑時用的(例如 JS 被擋)。JS 正常時,head 腳本一定會寫上 lightdark,這段幾乎不會插手。

通用範例用 :root:not([data-theme])(attribute 完全不存在才跟系統);本站用 :not([data-theme="light"])(沒鎖淺色就允許跟系統深色)。兩者目的相同,選擇器寫法不同,抄的時候別混用而不自知。

:root {
  --surface-page: #faf7f2;
  --ink-primary: #2c1810;
  --background-color: var(--surface-page);
  --text-color: var(--ink-primary);
}

[data-theme="dark"] {
  --surface-page: #1c1410;
  --ink-primary: #f0e6dc;
  --background-color: var(--surface-page);
  --text-color: var(--ink-primary);
}

html, body {
  color: var(--text-color);
  background-color: var(--background-color);
}

防 FOUC:先在 <head> 加 inline script

base.njk 在 stylesheet 之前執行這段同步腳本,先設定 data-theme,避免首屏先套錯顏色:

<script>
  (function() {
    const savedTheme = localStorage.getItem("theme");
    if (savedTheme) {
      document.documentElement.setAttribute("data-theme", savedTheme);
    } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
      document.documentElement.setAttribute("data-theme", "dark");
    } else {
      document.documentElement.setAttribute("data-theme", "light");
    }
  })();
</script>

與前文「通用推薦」相比,本站這段沒包 try/catch。若要在私密瀏覽也穩,把讀寫包進 try/catch 即可,邏輯不用改。

JS:初始、切換、系統同步

head 腳本只在 <html> 上寫了 data-theme還沒接按鈕site.js 載入後會再做三件事:

  1. 對齊 UI:依目前主題顯示月亮或太陽 icon,並更新 aria-label(例如「切換到淺色模式」)。
  2. 處理點擊:在 darklight 之間切換,寫入 localStorage,切換期間把按鈕設成 disabled 並加上 aria-busy,避免連點播兩次動畫。
  3. 聽系統偏好:僅在 localStorage 還沒有 theme 時,跟著 OS 切換。

本站的過場:淡入 + 一顆太陽

通用寫法只做「顏色慢慢淡過去」。本站多一顆太陽划過畫面:切到淺色像日出,切到深色像日落。兩件事分開做,大約都跑 1.5 秒。

  • 顏色淡入:還是 View Transition。瀏覽器不支援,顏色照樣立刻換。
  • 太陽:自己畫的 CSS 動畫(theme-transition.css)。跟 View Transition 無關,沒有 VT 也能播。
  • 不要動畫:系統開了「減少動態」,就只換顏色,太陽也不播。

點擊時大概是這樣(省略 icon):

if (shouldSkipAnimation()) {
  setTheme(nextTheme); // 減少動態:只換色
  return;
}

if (typeof document.startViewTransition !== 'function') {
  setTheme(nextTheme);
  playSunAnimation(nextTheme); // 沒有 VT,太陽仍可播
  return;
}

document.startViewTransition(() => setTheme(nextTheme));
playSunAnimation(nextTheme);

完整程式在 src/assets/js/theme-transition.js。動畫可有可無,顏色對才算過關

跟 Barba、Pagefind 共存

一般點連結會整頁重新載入,<head> 腳本會再跑一次,主題靠 localStorage 還在。本站用 Barba(一類 PJAX):換頁時只替換主內容,不重載整份 HTML。這對主題有兩個後果:

  • <html data-theme> 必須留在被替換的區塊外面。本站 data-barba="wrapper"<body>,真正被換的是裡面的 container;data-theme 掛在 <html>,換頁不會被清掉。
  • 切換按鈕在 header,同樣在 wrapper 外,元素不會被換掉。因此 initThemeToggle 只在第一次進站綁一次即可;若每次 Barba afterEnter 又綁一次,點一下會觸發兩次切換。

Pagefind 搜尋框是疊在頁面上的 overlay,樣式走同一批 CSS 變數(--background-color--text-color 等)。主題一切,搜尋層跟著變,不必替 Pagefind 再寫一套深色 CSS。

怎麼用 AI agent 完成整套主題切換?

這次沒有把需求拆成「先做按鈕、之後再補動畫」的零散修改,而是先請 AI agent 讀懂專案,再一次整理出完整狀態流程。這裡的 total dark mode switch function,不是只有把背景改黑,而是包含:

  • :root[data-theme="dark"] 的語意色 token
  • 首屏同步讀取 localStorage,避免 FOUC
  • 沒有手動選擇時,跟隨 prefers-color-scheme
  • 使用者按過按鈕後,保存手動選擇,不再被 OS 覆寫
  • 按鈕 icon、aria-labelaria-busy 與連點保護
  • View Transition 色彩 crossfade
  • 不支援 View Transition 時的直接切換
  • prefers-reduced-motion 下停用動畫
  • Barba 換頁後不重複綁定事件

先給 agent 正確上下文

好的 prompt 不只說「幫我加 dark mode」,而是把技術限制、現有檔案與驗收條件一次寫清楚。例如:

請先閱讀這個 Eleventy 專案的 layout、site.js、主題 CSS、Barba 初始化方式,
不要引入框架或新增不必要的套件。

請完成全站 dark/light mode:
1. 淺色是品牌預設,沒有 localStorage 時跟隨 prefers-color-scheme。
2. 使用 html[data-theme] 與語意 CSS variables。
3. head inline script 必須在 stylesheet 前執行,避免 FOUC。
4. theme-toggle 要更新 icon、aria-label、aria-busy,並避免重複綁定。
5. 切換時使用 document.startViewTransition;不支援或 reduced motion 時要能正常降級。
6. 保留 Barba 換頁與 Pagefind 搜尋功能。
7. 請列出修改檔案、可能的邊界情況,以及驗收步驟。

這樣 agent 會先處理「主題狀態由誰負責」的問題,而不是只在某個元件上加一段 .dark CSS。對本站而言,狀態放在 <html>,CSS 負責顏色,site.js 負責互動;這個分層先確定,後面的 View Transition 才不會變成另一套主題系統。

讓 agent 分階段實作與檢查

要求 agent 依下列順序工作,每完成一層就檢查一次:

  1. 盤點現況:找出 layout、token、theme toggle、Barba wrapper 和所有寫死色碼。
  2. 先完成可用版本data-theme、localStorage、系統偏好與 FOUC 防護。
  3. 補互動細節:按鈕狀態、鍵盤操作、失敗時的 fallback、系統偏好監聽。
  4. 最後加入動畫:把 setTheme() 包進 startViewTransition(),並以獨立 CSS animation 播放本站的日出/日落效果。
  5. 對照驗收清單:清除存檔、切換 OS 主題、停用 JavaScript、停用 View Transition、開啟 reduced motion,再測 Barba 換頁。

最後把 View Transition 與太陽動畫拆開:document.startViewTransition() 只負責主題色的 crossfade,.theme-transition__celestial-motion 則負責太陽移動。這個拆法很重要,因為瀏覽器不支援 View Transition 時,主題仍要能切換,太陽效果也不應阻止核心功能完成。

不要盲目接受 agent 的第一版

AI agent 可以快速補齊跨 HTML、CSS、JavaScript 的連動,但它不會自動知道哪些狀態是本站的產品決策。至少要人工確認:

  • localStorage 讀寫失敗時,私密瀏覽仍能切換。
  • data-theme="light" 能覆寫深色 OS,而不是被 @media 搶回去。
  • FOUC script 沒有使用 deferasync,且位於主要 stylesheet 之前。
  • startViewTransition 的 callback 只改主題,不把 overlay 一起拍進快照。
  • 動畫未結束、轉場拋錯或瀏覽器不支援時,仍會套用目標主題。
  • Barba 不會讓同一個按鈕累積多個 click listener。
  • 深色下文字、表單、搜尋 overlay、程式碼區塊與圖片仍然可讀。

AI agent 最適合處理「跨檔案的連動修改、邊界情況清單與初步驗收」,人仍要決定品牌色、動畫是否必要,以及什麼才算完成。這次的結果不是把 AI 產生的程式碼直接貼上,而是先讓 agent 理解架構,再用瀏覽器行為逐項驗證。

決策表:通用方式 vs 本站取捨

方案優點缺點通用建議kamadiam
prefers-color-scheme零 JS、無 FOUC不能手動切、不能記住極簡個人站可
data-theme + 淺色 :root + localStorage可切換、可記住;品牌淺色當預設需維護 JS 與 token✓ 多數內容站
<head> 同步腳本防 FOUC首屏深淺色較穩定多一段維護、需注意 CSP✓ 建議加
dark-first CSS(未設 light 就當深色)照顧深色 OS 首屏淺色首訪可能短閃深色品牌站可考慮
CSS light-dark()宣告較短要按鈕仍靠 JS;舊瀏覽器要 fallback新站、只跟 OS 時可未採用
切主題時用 View Transition換色比較不跳;不支援就瞬間切舊瀏覽器沒過場✓ 想加再加✓ 再加一顆太陽
build-time 多主題可換整套 layout要 rebuild/redeploy活動皮、多品牌與深淺色切換無關

怎麼選

  • 要訪客手動切換 → 用 data-theme
  • 品牌是暖色淺底(像 kamadiam)→ 淺色寫在 :root,深色當覆寫。
  • 在意首屏穩定 → 在 <head> 加同步腳本(kamadiam 已採用)。
  • 主題鈕想加過場 → 用 View Transition 包住改 data-theme;失敗就瞬間切色。

若 CSP(Content Security Policy)禁止 inline script,同步腳本會被擋,防 FOUC 失效。上線前確認政策允許這段,或改成 nonce/hash。

上線前驗收清單

  1. 系統淺色、首次進站 → 穩定顯示 light。
  2. 系統深色、首次進站 → 穩定顯示 dark(有同步腳本時不應先閃淺色)。
  3. 按切換鈕 → 立即切換,重新整理後仍記住。
  4. DevTools → Rendering → Emulate prefers-color-scheme;清掉 localStoragetheme 後應跟系統。
  5. 文字對比大致 ≥ 4.5:1;表單、<select> 在深色下可讀(可搭配 color-scheme)。
  6. Logo/PNG 在深色底沒有白邊(必要時準備 dark 版 SVG)。
  7. 若用 Barba 或類似 PJAX:確認 data-theme 掛在被替換的 container 外,且 listener 只綁一次。
  8. 開「減少動態」時仍能切主題,只是沒有過場。
  9. DevTools 關掉 View Transition(或用未支援的瀏覽器)→ 顏色仍立刻切對。

推薦工具/資源

資源適合誰一句話
本站 live 示範想先看成品再抄架構打開本站 header 的主題鈕,對照本文決策表
CSS 變數 + data-theme任何靜態站/11ty本文通用實作;不必上 React
light-dark()只要跟 OS、程式碼想最短官方文件可查;要按鈕仍回 data-theme
切主題過場按鈕換色想淡一點View Transition;不支援就瞬間切
託管(可選)做完主題、準備把靜態站推上線Hostinger 可當部署選項之一;主題邏輯與主機無關

結語

深淺色能用,靠兩件事:語意 token(元件不寫死色碼)與 html 上的 data-theme。建議順序:系統預設 → 手動可覆寫 → <head> 防閃爍。過場可加、可不加。本站淺色當預設,深色靠 data-theme;切主題時顏色淡入,再加一顆太陽。

建議先到本站實際切換一次,對照本文決策表,再把同一套接到你的 Eleventy base layout。

參考資料