Sass 多主題切換:與 CSS 自訂屬性的分工
多主題是 Sass 與 CSS 自訂屬性 分工最清楚的例子:
Sass 負責在編譯期產生那份對照表,切換的那一刻是 CSS 變數的事。
原因很簡單,Sass 變數 編譯完就消失了,瀏覽器裡沒有東西可以改。
標準做法
一、用 map 定義每個主題的值。
// abstracts/_themes.scss
$themes: (
light: (
bg: #ffffff,
surface: #f8f9fa,
text: #212529,
border: #dee2e6,
),
dark: (
bg: #1a1a1a,
surface: #242424,
text: #f1f3f5,
border: #373a40,
),
) !default;二、用 @each 產生自訂屬性。
@use "sass:map";
@mixin theme-vars($name) {
@each $key, $value in map.get($themes, $name) {
--#{$key}: #{$value};
}
}這裡的插值不能省
--bg: $value; 會原樣輸出字面上的 $value。自訂屬性的值不會被 Sass 處理,必須用 插值 寫成 #{$value}。
三、輸出三份宣告。
: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); // 手動選淺色
}四、樣式只認變數。
.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
const root = document.documentElement;
function setTheme(mode) {
if (mode === "system") {
root.removeAttribute("data-theme");
} else {
root.dataset.theme = mode;
}
localStorage.setItem("theme", mode);
}避免首次載入的閃爍
在 <head> 裡放一小段 同步執行 的腳本,在第一次繪製前就決定好:
<script>
(function () {
var t = localStorage.getItem("theme");
if (t && t !== "system") document.documentElement.dataset.theme = t;
})();
</script>寫在樣式表之後、<body> 之前,不要用 defer 或 async。
元件內部的區域主題
反向嵌套(把 & 放後面)讓「深色時的差異」寫在元件自己的檔案裡:
.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 算:
@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:
@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 體積翻倍或選取器全部加一層前綴,現在幾乎沒有理由這樣做。