現在不管是 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 Code(astro-expressive-code)——Astro 生態系最成熟的 code block 套件,Starlight 文件網站也是用它。內建複製按鈕(有「已複製」的狀態回饋)、行號(穩定不跑位)、frame title(標示 code 在哪個檔案)。原來真的有,而且功能比預期的還完整。
pnpm add astro-expressive-code @expressive-code/plugin-line-numbersastro.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 體驗大升級,這些細節很值得。