介面顏色換完之後,剩下的破綻幾乎都在圖片上:白底 PNG logo 在深色頁面變成一塊刺眼的亮斑、截圖裡的白色瀏覽器介面在暗色文章中格外突兀、SVG 圖示因為填了寫死的 #333 而消失。這些問題各有不同的正確解法,不能全部靠一個 filter: invert() 蓋過去。

決策樹:這張圖該用哪種做法

圖的類型正確做法
圖示(單色、功能性)inline SVG + currentColor
Logo(多色、有品牌規範)<picture> 提供兩個檔案
插畫(自製、可控)inline SVG 內用 CSS 變數,或提供兩版
照片、螢幕截圖保留原圖,加保護底色與細微降低亮度
圖表、資料視覺化用 token 重繪,不要用 filter
使用者上傳內容一律不動,只加底色

圖示:currentColor 是唯一正確答案

單色圖示不該有自己的顏色,它應該繼承文字色:

<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
  <path d="M4 10h12M10 4v12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

只要外層文字色是 token 化的,圖示自動在兩套主題正確,也自動在 hover、disabled、錯誤狀態下正確——這是 filter: invert() 永遠做不到的,因為 filter 只知道反轉,不知道語意。

currentColor 只在 inline SVG 或用 <use> 引入同源 SVG 時生效。<img src="icon.svg"> 引入的 SVG 是獨立文件,繼承不到外層的 color,這是最常見的誤解。若因為快取或架構因素必須用 <img>,退而求其次的做法是 CSS mask:

.icon {
  background-color: currentColor;              /* 顏色由 CSS 控制 */
  mask: url("/icons/plus.svg") center / contain no-repeat;
  width: 20px; height: 20px;
}

這個做法把 SVG 當形狀、顏色交給 CSS,效果等同 currentColor,且可以用 <img> 一樣的快取策略。

Logo:用 <picture> 提供兩個檔案

品牌 logo 通常有多色與明確規範,反轉會違反規範。正解是準備兩個檔案,讓瀏覽器依偏好挑:

<picture>
  <source srcset="/logo-dark.svg"  media="(prefers-color-scheme: dark)">
  <img src="/logo-light.svg" alt="站名" width="120" height="32">
</picture>

但這個寫法有一個必須知道的限制:<picture> 只認得系統偏好,不認得你的手動覆寫。 使用者在系統亮色下手動切成暗色主題時,logo 仍會是亮色版。

若產品有手動切換,要用 CSS 控制可見性而非 <picture>

<img class="logo logo--light" src="/logo-light.svg" alt="站名">
<img class="logo logo--dark"  src="/logo-dark.svg"  alt="" aria-hidden="true">
.logo--dark  { display: none; }
:root[data-theme="dark"] .logo--light { display: none; }
:root[data-theme="dark"] .logo--dark  { display: block; }
 
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .logo--light { display: none; }
  :root:not([data-theme="light"]) .logo--dark  { display: block; }
}

代價是兩個檔案都會被下載。對一個 3KB 的 SVG logo 這通常可接受;對大圖則應該用 inline SVG + CSS 變數的做法避免雙下載。

支援 light-dark() 影像值的環境可以更簡潔,但要用 @supports 保護,因為影像值的支援度落後於顏色值:

@supports (background-image: light-dark(url("a.svg"), url("b.svg"))) {
  .logo {
    background-image: light-dark(url("/logo-light.svg"), url("/logo-dark.svg"));
  }
}

插畫:inline SVG + CSS 變數

自製插畫最理想的做法是把它 inline 進 HTML,內部用變數:

<svg viewBox="0 0 200 120" role="img" aria-label="流程示意">
  <style>
    .bg   { fill: var(--illus-bg, #f2f4f7); }
    .line { stroke: var(--illus-line, #98a2b3); }
    .key  { fill: var(--illus-key, #3b6fd4); }
  </style>
  <rect class="bg" width="200" height="120" rx="8"/>
  <path class="line" d="M20 60h160" stroke-width="2"/>
</svg>
:root {
  --illus-bg:   light-dark(#f2f4f7, #1c1f24);
  --illus-line: light-dark(#98a2b3, #5b6470);
  --illus-key:  light-dark(#3b6fd4, #8fb3f0);
}

外連的 .svg 檔案沒辦法讀到父文件的自訂屬性(它是獨立的樣式脈絡),但它可以自己寫媒體查詢

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120">
  <style>
    :root { color-scheme: light dark; }
    .bg { fill: light-dark(#f2f4f7, #1c1f24); }
  </style>
  ...
</svg>

這個做法在 <img> 引入時可運作(SVG 內的媒體查詢依父層 color-scheme 求值),但同樣只跟隨系統偏好、不跟隨手動覆寫。

照片與螢幕截圖:不要反轉

照片絕對不能反轉——人臉會變成負片。web.dev 提到的研究指出使用者在暗色模式下偏好較不鮮豔的影像,因此可行的做法是細微降低亮度或飽和度,而不是改變它:

@media (prefers-color-scheme: dark) {
  img:not([src$=".svg"]):not(.no-dim) {
    filter: brightness(0.88) saturate(0.9);
  }
}

這個處理要非常克制。降太多會讓內容看起來像壞掉、也會破壞需要真實呈現的內容(商品圖、醫療影像、設計作品)。給一個 .no-dim 的逃生門是必要的。

螢幕截圖是最麻煩的一類,因為截圖裡通常有大面積白色介面。三個實際做法,依成本排序:

  1. 加圓角邊框與內縮,讓白色區塊看起來像刻意的「畫框」而非破圖。成本最低,效果意外地好。
  2. 重新截一份暗色版,用 <picture> 或 class 切換。成本高但效果最好,適合文件站與產品官網。
  3. 改用可縮放的向量重繪(用 SVG 或直接用 HTML/CSS 重現介面),一勞永逸且可被搜尋、可被翻譯。

無論哪種,都應該給圖片一個保護底色,避免透明 PNG 在深色背景上出現不可預期的結果:

img { background-color: light-dark(#ffffff, #1c1f24); }

使用者上傳內容:一律不動

留言區的圖、頭像、商品照片,這些內容你無法預測,任何 filter 都可能產生災難。正確做法是不處理,只確保容器有合理底色與邊框,讓它在深色頁面上看起來是「一張圖」而不是「一塊破洞」。

favicon 與 OG 圖

favicon 也有雙主題版本,做法是在 SVG favicon 內寫媒體查詢:

<!-- favicon.svg -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
  <style>
    path { fill: #16191d; }
    @media (prefers-color-scheme: dark) { path { fill: #e6e8eb; } }
  </style>
  <path d="..."/>
</svg>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="32x32">   <!-- 舊瀏覽器 fallback -->

支援度依瀏覽器而異,且分頁列的背景色未必等於系統偏好,所以不要依賴它——比較保險的是選一個在深淺底上都能看清楚的 favicon 設計(有描邊或有底色),這比做兩版更耐用。

OG 分享圖(og:image)沒有雙主題機制,社群平台會用自己的背景顯示它。做法是永遠給 OG 圖一個不透明底色,不要用透明背景,否則在不同平台的深淺介面上會有不同結果。

影片與 Canvas

<video> 內容不做處理,但海報圖(poster)與播放器控制列要跟著主題走。Canvas 與 WebGL 完全不吃 CSS,主題切換時必須主動重繪:

function onThemeChange() {
  const cs = getComputedStyle(document.documentElement);
  chart.setColors({
    axis: cs.getPropertyValue('--text-muted').trim(),
    grid: cs.getPropertyValue('--border-subtle').trim(),
  });
  chart.render();
}

從 CSS 自訂屬性讀顏色而不是在 JS 裡另寫一份色表,是避免兩份顏色來源不同步的關鍵。這一點在圖表庫的整合上特別容易出錯。

下一步:原生控件、捲軸與 forced-colors