現在不管是 App 還是作業系統,幾乎都有 dark mode。工程師族群尤其習慣開著深色模式工作——長時間盯著螢幕,眼睛比較不累。

既然自己也是這樣用的,blog 當然也需要有這個功能。於是我跟 AI 一起來實作。

沒有這個習慣的使用者,則可以保持白底介面,不影響原本的閱讀習慣。

實作起來沒有想像中複雜,主要是 CSS 這邊的調整就可以搞定。但做完第一版、切到 light mode 之後,我發現 code block 沒有跟著變——它還是深色的。這才開始了一系列的討論,去找真正的解決方案。

這篇記錄的就是這個過程:Dark/Light mode 切換的實作,以及後來發現 code block 需要另外處理——才能真正完成一套完整的主題切換體驗。


CSS 架構:從 media query 改成 class-based

最直觀的做法是用 @media (prefers-color-scheme: dark) 跟著系統走。但這樣沒有辦法手動切換——使用者按了 toggle,沒有任何地方可以寫入「我要覆蓋系統設定」。

改成 class-based 是標準解法:

/* 淺色(預設) */
:root {
--bg: #ffffff;
--text: #111111;
}
/* 深色:html 加上 .dark class 就切換 */
html.dark {
--bg: #111111;
--text: #f0f0f0;
}

Toggle 按下去,JS 在 <html> 加或移除 .dark,整頁顏色跟著 CSS 變數走。


防閃爍:UX 細節,但讀者感受得到

做完 dark mode 之後,還有一個細節要處理。

如果你選了 dark mode,每次重新整理頁面,它會先閃一下淺色再切回去——只有零點幾秒,但對長期用深色模式的人來說,那一閃會很突兀,像螢幕突然打了你一下。

這個問題叫 FOUC(Flash of Unstyled Content)。解法是在 <head> 最早的位置放一段 inline script,在任何 CSS 執行之前就把 class 設好:

const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved ?? (prefersDark ? 'dark' : 'light');
document.documentElement.classList.add(theme);

這段要在所有 <link><style> 之前執行,否則還是會閃。


Code Block 也要跟著 Theme 走

白色背景有了、可以手動切換了,但在白色背景下 code block 卻還是黑色的一塊——整體不一致,看起來很突兀。

於是跟 AI 討論:

「Code block 要有行號跟複製按鈕吧?另外,幫我評估一下,可不可以讓它跟著 dark/light mode 換 theme?」

AI 開始動手:CSS counter 做行號、JS 寫 copy 邏輯,然後去爬 light mode 的 CSS 準備手動塞進去……

我直覺這種東西應該是改 config 就能做到,方向不對,馬上按 Esc 把 AI 叫停:

「不可能連行號和複製按鈕都要自己刻吧?GitHub 上一定有更完整的 code block 套件,幫我找。」

AI 找回來的答案是 Expressive Codeastro-expressive-code)——Astro 生態系最成熟的 code block 套件,Starlight 文件網站也是用它。內建複製按鈕(有「已複製」的狀態回饋)、行號(穩定不跑位)、frame title(標示 code 在哪個檔案)。原來真的有,而且功能比預期的還完整。

Terminal window
pnpm add astro-expressive-code @expressive-code/plugin-line-numbers

astro.config.mjs 加上:

import expressiveCode from 'astro-expressive-code';
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';
export default defineConfig({
integrations: [
expressiveCode({
plugins: [pluginLineNumbers()],
themes: ['monokai', 'github-light'],
}),
],
});

換完之後,手刻的行號 CSS、copy button script 全部拔掉,code block 的 UX 反而比之前好。


兩個系統,一個你沒預料到的連結

設定 Expressive Code 雙 theme 時,加了 themes: ['monokai', 'github-light'],以為 html.dark / html.light class 就會控制切換。

Build 完打開 light mode,code block 還是深色。

去看 build 出來的 CSS,才發現 EC 完全不看 CSS class——它用的是 data-theme attribute:

/* Monokai:預設,:root 就套 */
:root { --ec-codeBg: #272822; }
/* GitHub Light:要 <html data-theme="github-light"> 才觸發 */
:root[data-theme='github-light'] .expressive-code {
--ec-codeBg: #ffffff;
}

文件沒有說清楚這件事,要看 build output 才能發現。

所以 toggle JS 除了換 class,還要同步 set 和 remove data-theme

function setTheme(theme) {
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'github-light');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('theme', theme);
}

防閃爍的 inline script 也要同步加上 data-theme 設定,否則頁面載入時 code block 仍然會閃。


心得

Dark mode 跟 code block 看起來是兩件獨立的事,但它們最後在同一個 toggle function 裡交會。做完之後,讀者切換主題的那一秒,整頁——包括 code block——同步切換,UX 體驗大升級,這些細節很值得。