Skip to content

Sass 多主題切換:與 CSS 自訂屬性的分工

用 Sass 實作多主題切換

多主題是 Sass 與 CSS 自訂屬性 分工最清楚的例子:

Sass 負責在編譯期產生那份對照表,切換的那一刻是 CSS 變數的事。

原因很簡單,Sass 變數 編譯完就消失了,瀏覽器裡沒有東西可以改。

標準做法

一、用 map 定義每個主題的值。

scss
// abstracts/_themes.scss
$themes: (
  light: (
    bg:      #ffffff,
    surface: #f8f9fa,
    text:    #212529,
    border:  #dee2e6,
  ),
  dark: (
    bg:      #1a1a1a,
    surface: #242424,
    text:    #f1f3f5,
    border:  #373a40,
  ),
) !default;

二、用 @each 產生自訂屬性。

scss
@use "sass:map";

@mixin theme-vars($name) {
  @each $key, $value in map.get($themes, $name) {
    --#{$key}: #{$value};
  }
}

這裡的插值不能省

--bg: $value; 會原樣輸出字面上的 $value。自訂屬性的值不會被 Sass 處理,必須用 插值 寫成 #{$value}

三、輸出三份宣告。

scss
:root {
  @include theme-vars(light);          // 預設淺色
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    @include theme-vars(dark);          // 跟隨系統
  }
}

:root[data-theme="dark"] {
  @include theme-vars(dark);            // 手動選深色
}

:root[data-theme="light"] {
  @include theme-vars(light);           // 手動選淺色
}

四、樣式只認變數。

scss
.card {
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
}

之後不管切到哪個主題,.card 一個字都不用改。

三種狀態要都涵蓋

主題設定有三種狀態,缺一份就會出錯:

使用者的選擇靠什麼生效
跟隨系統(預設)prefers-color-scheme 媒體查詢
手動選深色[data-theme="dark"]
手動選淺色[data-theme="light"]

最容易漏的是「系統深色但手動選淺色」

沒有寫 [data-theme="light"] 那一份的話,系統設為深色的使用者就切不回淺色。而 :not([data-theme="light"]) 那個否定條件,是為了讓手動選擇能蓋過媒體查詢。

切換的 JavaScript

js
const root = document.documentElement;

function setTheme(mode) {
  if (mode === "system") {
    root.removeAttribute("data-theme");
  } else {
    root.dataset.theme = mode;
  }
  localStorage.setItem("theme", mode);
}

避免首次載入的閃爍

<head> 裡放一小段 同步執行 的腳本,在第一次繪製前就決定好:

html
<script>
  (function () {
    var t = localStorage.getItem("theme");
    if (t && t !== "system") document.documentElement.dataset.theme = t;
  })();
</script>

寫在樣式表之後、<body> 之前,不要用 deferasync

元件內部的區域主題

反向嵌套(把 & 放後面)讓「深色時的差異」寫在元件自己的檔案裡:

scss
.card {
  background: var(--surface);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);

  :root[data-theme="dark"] & {
    box-shadow: none;
    border: 1px solid var(--border);
  }
}

寫法見 父選取器

色階由主色推導

只維護幾個基準色,其餘用 sass:color 算:

scss
@use "sass:color";

@function build-theme($brand, $bg, $text) {
  @return (
    brand:       $brand,
    brand-hover: color.scale($brand, $lightness: -12%),
    brand-soft:  color.mix($brand, $bg, 12%),
    bg:          $bg,
    text:        $text,
    text-muted:  color.mix($text, $bg, 65%),
  );
}

$themes: (
  light: build-theme(#2965f1, #ffffff, #212529),
  dark:  build-theme(#4d82f3, #1a1a1a, #f1f3f5),
);

改主色只要改一個參數,整組色階跟著走。

純 Sass 的替代方案

不用自訂屬性的話,只能產生兩份完整的 CSS:

scss
@each $name, $tokens in $themes {
  .theme-#{$name} {
    .card {
      background: map.get($tokens, surface);
    }
  }
}
自訂屬性純 Sass
切換方式改一個屬性換掉整個外層類別
CSS 體積一份 + 幾組變數每個主題各一整份
選取器不變全部多一層前綴
執行時新增主題可以不行

幾乎沒有理由選純 Sass 了

它只在必須支援不支援自訂屬性的老舊瀏覽器時才有意義。細節見 搭配 CSS 自訂屬性

常見問題

為什麼主題不能純用 Sass 變數做?

因為 Sass 變數在編譯期就被換成固定值,產出的 CSS 裡不存在,無法在瀏覽器裡改變。要能即時切換就必須把值放進 CSS 自訂屬性,由 Sass 負責產生那份宣告。

怎麼同時支援系統偏好和手動切換?

預設用淺色,在深色偏好的媒體查詢裡覆蓋成深色,再用根元素上的資料屬性讓手動選擇蓋過媒體查詢。淺色的手動選擇也要寫一份,否則系統是深色時切不回淺色。

切換主題時畫面會閃一下白色怎麼辦?

那是首次載入時樣式表還沒套用造成的。在 <head> 裡放一小段同步執行的腳本,從儲存的偏好讀出主題並立刻寫到根元素上,就能在第一次繪製前決定好。

產生兩份完整 CSS 的做法還適用嗎?

只有在必須支援不支援自訂屬性的老舊瀏覽器時才需要。它會讓 CSS 體積翻倍或選取器全部加一層前綴,現在幾乎沒有理由這樣做。

延伸閱讀