搜「dark mode CSS」,多半會看到 React、Tailwind 範例。若你做的是 Eleventy/純 HTML 靜態站,直接照搬常會卡在這三件事:
- 顏色寫死在元件上,深色模式要改一堆檔案。
- 重新整理時先閃白再變黑(典型的 FOUC)。
- 手動切換後,又跟系統偏好打架。
這篇先介紹一套可上線的通用做法(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: #fff、color: #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 還沒載入」,而是第一幀的顏色跟最終主題不一致。典型順序:
- CSS 先依
@media (prefers-color-scheme: dark)畫出深色。 - 外部 JS 稍後才讀到
localStorage裡存的light。 - 再把畫面改回淺色。使用者會看到閃一下。
解法:在 stylesheet 之前放一段同步 inline script,在瀏覽器畫第一幀前就寫好 data-theme。不要用 defer 或 async,那兩種都會晚跑,趕不上第一幀。
<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。點主題鈕時,它會:
- 先拍一張「現在」的畫面
- 等你改完
data-theme - 再拍「之後」的畫面,兩張交叉淡入
主題鈕的最小寫法:把「改顏色」包進 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.njk、src/assets/js/site.js 為準)。
第一次進站的判斷順序:
localStorage的theme有值 → 用存檔。- 沒有存檔 → 依
prefers-color-scheme決定 dark 或 light。
同一套判斷出現兩次,分工不同:<head> 的 inline script 趕在第一幀前寫上 data-theme;site.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 變數,不必維護兩份完整主題。
三層選擇器各管一件事:
:root放淺色:JS 還沒跑、attribute 也還沒設時的底線。全站元件只寫var(--text-color),不必每個區塊都寫深/淺兩套。[data-theme="dark"]覆寫變數:JS 只要setAttribute("data-theme", "dark"),深色 token 就生效。這也讓使用者能在系統是深色時,強制選淺色。@media當備援:本站用:root:not([data-theme="light"]),意思是「只要不是明確鎖定淺色,系統深色就套深色 token」。這是給腳本沒跑時用的(例如 JS 被擋)。JS 正常時,head 腳本一定會寫上light或dark,這段幾乎不會插手。
通用範例用 :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 載入後會再做三件事:
- 對齊 UI:依目前主題顯示月亮或太陽 icon,並更新
aria-label(例如「切換到淺色模式」)。 - 處理點擊:在
dark/light之間切換,寫入localStorage,切換期間把按鈕設成disabled並加上aria-busy,避免連點播兩次動畫。 - 聽系統偏好:僅在
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只在第一次進站綁一次即可;若每次 BarbaafterEnter又綁一次,點一下會觸發兩次切換。
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-label、aria-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 依下列順序工作,每完成一層就檢查一次:
- 盤點現況:找出 layout、token、theme toggle、Barba wrapper 和所有寫死色碼。
- 先完成可用版本:
data-theme、localStorage、系統偏好與 FOUC 防護。 - 補互動細節:按鈕狀態、鍵盤操作、失敗時的 fallback、系統偏好監聽。
- 最後加入動畫:把
setTheme()包進startViewTransition(),並以獨立 CSS animation 播放本站的日出/日落效果。 - 對照驗收清單:清除存檔、切換 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 沒有使用
defer或async,且位於主要 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。
上線前驗收清單
- 系統淺色、首次進站 → 穩定顯示 light。
- 系統深色、首次進站 → 穩定顯示 dark(有同步腳本時不應先閃淺色)。
- 按切換鈕 → 立即切換,重新整理後仍記住。
- DevTools → Rendering → Emulate
prefers-color-scheme;清掉localStorage的theme後應跟系統。 - 文字對比大致 ≥ 4.5:1;表單、
<select>在深色下可讀(可搭配color-scheme)。 - Logo/PNG 在深色底沒有白邊(必要時準備 dark 版 SVG)。
- 若用 Barba 或類似 PJAX:確認
data-theme掛在被替換的 container 外,且 listener 只綁一次。 - 開「減少動態」時仍能切主題,只是沒有過場。
- 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。
參考資料
- MDN: prefers-color-scheme:系統深淺色媒體查詢的行為說明。
- MDN: light-dark():依
color-scheme在兩個顏色間取值的 CSS 函式。 - web.dev: CSS color-scheme-dependent colors with light-dark():
color-scheme與light-dark()如何搭配、以及與媒體查詢的差異。 - MDN: color-scheme:讓原生控件/捲軸跟隨深淺色,並啟用
light-dark()。 - MDN: View Transition API:切主題用的
startViewTransition。 - MDN: Using the View Transition API:拍照、改 DOM、淡入的流程。
- MDN: prefers-reduced-motion:使用者要求減少動畫時,應跳過過場、只換顏色。
