建立主題切換元件

說明如何建構可自訂且無障礙的主題切換元件。

在本篇文章中,我想分享如何建構深色和淺色主題切換元件的想法。試用示範模式

「Demo」按鈕大小已加大,方便查看

如果你偏好觀看影片,請參閱這篇文章的 YouTube 版本:

總覽

網站可能會提供設定,用於控制色彩配置,而非完全依賴系統偏好設定。也就是說,使用者可以透過系統偏好設定以外的模式瀏覽網頁。舉例來說,使用者的系統採用淺色主題,但使用者偏好以深色主題顯示網站。

建構這項功能時,需要考量幾項網路工程相關事項。舉例來說,瀏覽器應盡快瞭解偏好設定,以免網頁顏色閃爍,且控制項必須先與系統同步,然後允許用戶端儲存例外狀況。

這張圖表顯示 JavaScript 網頁載入和文件互動事件的預覽畫面,整體而言,這張圖表顯示設定主題有 4 種路徑。

標記

應使用 <button> 來切換,這樣就能享有瀏覽器提供的互動事件和功能,例如點擊事件和焦點功能。

按鈕

按鈕需要一個用於 CSS 的類別,以及一個用於 JavaScript 的 ID。此外,由於按鈕內容是圖示而非文字,請新增title屬性,提供按鈕用途的相關資訊。最後,請新增 [aria-label] 來保留圖示按鈕的狀態,讓螢幕閱讀器可將主題的狀態分享給視障人士。

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto"
>
  …
</button>

aria-labelaria-live 禮貌

如要向螢幕閱讀器指出應宣讀 aria-label 的變更,請將 aria-live="polite" 新增至按鈕。

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto" 
  aria-live="polite"
>
  …
</button>

這個標記新增項目會向螢幕閱讀器發出信號,以禮貌的方式告訴使用者變更內容,而非使用 aria-live="assertive"。在這個按鈕的情況下,系統會根據 aria-label 的狀態,宣布「light」或「dark」。

可縮放向量圖形 (SVG) 圖示

SVG 可讓您以最少的標記建立高品質可調整大小的圖形。與按鈕互動時,可觸發向量圖形的新視覺狀態,因此 SVG 非常適合用於圖示。

以下 SVG 標記會放在 <button> 中:

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  …
</svg>

aria-hidden 已新增至 SVG 元素,因此螢幕閱讀器會知道要忽略該元素,因為它已標示為呈現元素。這非常適合用於視覺裝飾,例如按鈕內的圖示。除了元素上必要的 viewBox 屬性外,也應基於圖片應取得內嵌大小的類似原因,新增高度和寬度。

太陽

顯示太陽圖示,其中太陽光線會淡出,而粉紅色箭頭會指向中央的圓形。

太陽圖形由圓形和線條組成,SVG 方便地提供這些圖形。將 cxcy 屬性設為 12 (即可視區域大小 (24) 的一半),然後為 6 指定半徑 (r),即可將 <circle> 置中,並設定大小。

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>

此外,遮罩屬性會指向 SVG 元素的 ID,您會在下一個步驟建立這個 ID,最後使用 currentColor 提供與網頁文字顏色相符的填滿顏色。

太陽光束

顯示太陽圖示,其中太陽中心已淡出,粉紅色箭頭指向太陽光芒。

接著,在圓圈下方,群組元素 <g> 群組內新增太陽光線線條。

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    <line x1="12" y1="1" x2="12" y2="3" />
    <line x1="12" y1="21" x2="12" y2="23" />
    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
    <line x1="1" y1="12" x2="3" y2="12" />
    <line x1="21" y1="12" x2="23" y2="12" />
    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
  </g>
</svg>

這次,我們將設定每個線條的stroke,而非fill 的值為 currentColor。線條加上圓形形狀,就能製作出有光芒的太陽。

月亮

為了營造光明 (太陽) 與黑暗 (月亮) 之間的無縫轉換效果,月亮是使用 SVG 遮罩增強太陽圖示。

<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    …
  </g>
  <mask class="moon" id="moon-mask">
    <rect x="0" y="0" width="100%" height="100%" fill="white" />
    <circle cx="24" cy="10" r="6" fill="black" />
  </mask>
</svg>
這張圖片包含三個垂直圖層,可說明遮罩的運作方式。最上層是白色方塊,內含黑色圓形。中間層是太陽圖示。底層標示為結果,顯示太陽圖示,並在頂層黑色圓圈處加上裁剪效果。

使用 SVG 的遮罩功能相當強大,可透過白色和黑色移除或加入其他圖形的部分。只要將圓形圖形移入或移出遮罩區域,即可使用 SVG 遮罩遮蔽太陽圖示,並顯示月亮 <circle> 圖形。

如果 CSS 無法載入,會發生什麼情況?

螢幕截圖:純文字瀏覽器按鈕,內含太陽圖示。

建議您測試 SVG,假設 CSS 未載入,以確保結果不會過大或導致版面配置問題。SVG 上的內嵌高度和寬度屬性,加上 currentColor 的使用方式,可在 CSS 未載入時,為瀏覽器提供最少的樣式規則。這可提供不錯的防禦方式,對抗網路不穩定情形。

版面配置

主題切換元件只有很小的表面區域,因此不需要使用格線或彈性容器進行版面配置。而是使用 SVG 定位和 CSS 轉換。

樣式

.theme-toggle 種樣式

<button> 元素是圖示形狀和樣式的容器。這個父項內容會保留自適應顏色和大小,以便傳遞至 SVG。

第一項工作是將按鈕設為圓形,並移除預設的按鈕樣式:

.theme-toggle {
  --size: 2rem;
  
  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
}

接下來,請新增一些互動樣式。新增滑鼠使用者的游標樣式。新增 touch-action: manipulation,即可快速回應觸控體驗。移除 iOS 套用至按鈕的半透明醒目顯示效果。最後,請為焦點狀態的輪廓留出一些空間,讓元素的邊緣有呼吸空間:

.theme-toggle {
  --size: 2rem;

  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;
}

按鈕內的 SVG 也需要一些樣式。SVG 應符合按鈕的大小,並為視覺效果柔和,請將線條兩端圓滑化:

.theme-toggle {
  --size: 2rem;

  background: none;
  border: none;
  padding: 0;

  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;

  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;

  & > svg {
    inline-size: 100%;
    block-size: 100%;
    stroke-linecap: round;
  }
}

使用 hover 媒體查詢功能支援自適應大小

2rem 中,圖示按鈕的大小有點小,雖然對使用滑鼠的使用者來說沒問題,但對於像手指這類粗略的指標來說,可能會很難操作。使用懸停媒體查詢指定大小增加值,讓按鈕符合多項觸控大小規範

.theme-toggle {
  --size: 2rem;
  
  
  @media (hover: none) {
    --size: 48px;
  }
}

太陽和月亮 SVG 樣式

按鈕會保留主題切換元件的互動元素,而內部的 SVG 則會保留視覺和動畫元素。這就是讓圖示變得美觀且栩栩如生的關鍵。

淺色主題

ALT_TEXT_HERE

如要從 SVG 圖形的中心進行縮放和旋轉動畫,請設定 transform-origin: center center。按鈕提供的自動調整色彩會在此由形狀使用。月亮和太陽使用 var(--icon-fill)var(--icon-fill-hover) 按鈕填滿,而陽光則使用變數的筆觸。

.sun-and-moon {
  & > :is(.moon, .sun, .sun-beams) {
    transform-origin: center center;
  }

  & > :is(.moon, .sun) {
    fill: var(--icon-fill);

    @nest .theme-toggle:is(:hover, :focus-visible) > & {
      fill: var(--icon-fill-hover);
    }
  }

  & > .sun-beams {
    stroke: var(--icon-fill);
    stroke-width: 2px;

    @nest .theme-toggle:is(:hover, :focus-visible) & {
      stroke: var(--icon-fill-hover);
    }
  }
}

深色主題

ALT_TEXT_HERE

月亮樣式需要移除太陽光線、放大太陽圓形,並移動圓形遮罩。

.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }

    & > .sun-beams {
      opacity: 0;
    }

    & > .moon > circle {
      transform: translateX(-7px);

      @supports (cx: 1px) {
        transform: translateX(0);
        cx: 17px;
      }
    }
  }
}

請注意,深色主題沒有顏色變更或轉場效果。父項按鈕元件擁有顏色,這些顏色已在深色和淺色情境中進行調整。轉場資訊應置於使用者的動畫偏好設定媒體查詢之後。

動畫

按鈕應具備功能和狀態,但目前沒有轉換。以下各節將說明如何定義轉場方式轉場內容

共用媒體查詢和匯入轉場效果

為了讓轉場和動畫能輕鬆套用使用者的作業系統動態偏好設定,PostCSS 外掛程式 Custom Media 可讓您使用為媒體查詢變數起草的 CSS 規格語法:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

/* usage example */
@media (--motionOK) {
  .sun {
    transition: transform .5s var(--ease-elastic-3);
  }
}

如要使用獨特且易於使用的 CSS 緩和效果,請匯入 Open Props緩和效果部分:

@import "https://unpkg.com/open-props/easings.min.css";

/* usage example */
.sun {
  transition: transform .5s var(--ease-elastic-3);
}

太陽

太陽的轉場效果比月亮更有趣,可透過彈跳緩和效果達成。太陽光線在旋轉時應稍微彈跳,太陽的中心在縮放時也應稍微彈跳。

預設 (淺色主題) 樣式會定義轉場效果,而深色主題樣式則會定義轉換至淺色主題的自訂設定:

​​.sun-and-moon {
  @media (--motionOK) {
    & > .sun {
      transition: transform .5s var(--ease-elastic-3);
    }

    & > .sun-beams {
      transition: 
        transform .5s var(--ease-elastic-4),
        opacity .5s var(--ease-3)
      ;
    }

    @nest [data-theme="dark"] & {
      & > .sun {
        transform: scale(1.75);
        transition-timing-function: var(--ease-3);
        transition-duration: .25s;
      }

      & > .sun-beams {
        transform: rotateZ(-25deg);
        transition-duration: .15s;
      }
    }
  }
}

在 Chrome 開發人員工具的「動畫」面板中,您可以找到動畫轉場的時間軸。您可以檢查總動畫的時間長度、元素和漸變時間。

淺色至深色轉場
深色至淺色轉場

月亮

月亮的亮度和深色位置已設定完成,請在 --motionOK 媒體查詢中加入轉場樣式,讓月亮動畫呈現,同時尊重使用者的動畫偏好設定。

延遲時間和持續時間是讓這項轉場效果看起來乾淨的關鍵。舉例來說,如果太陽太早遮蔽,轉場效果就不會顯得有條理或有趣,反而會讓人覺得混亂。

​​.sun-and-moon {
  @media (--motionOK) {
    & .moon > circle {
      transform: translateX(-7px);
      transition: transform .25s var(--ease-out-5);

      @supports (cx: 1px) {
        transform: translateX(0);
        cx: 17px;
        transition: cx .25s var(--ease-out-5);
      }
    }

    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
淺色至深色轉場
深色至淺色轉場

偏好減少動態效果

在大多數 GUI 挑戰中,我會為偏好減少動畫的使用者保留部分動畫,例如不透明度交叉淡出效果。不過,這個元件在即時狀態變更時,會讓人有更好的體驗。

JavaScript

這個元件中的 JavaScript 有許多工作,從管理螢幕閱讀器的 ARIA 資訊,到從本機儲存空間取得及設定值。

網頁載入體驗

請務必避免在網頁載入時出現顏色閃爍。如果使用者使用深色配置,並表示偏好此元件的淺色版本,然後重新載入網頁,網頁一開始會是深色,然後閃爍變成淺色。為了避免這種情況,我們會執行少量封鎖 JavaScript,目標是盡早設定 HTML 屬性 data-theme

<script src="./theme-toggle.js"></script>

為達成此目的,系統會先載入文件 <head> 中的純文字 <script> 標記,再載入任何 CSS 或 <body> 標記。當瀏覽器遇到這類未標示的指令碼時,就會在 HTML 的其餘部分之前執行程式碼。適度使用這個阻斷時刻,即可在主要 CSS 著色網頁前設定 HTML 屬性,進而避免閃爍或顏色問題。

JavaScript 會先在本機儲存空間中檢查使用者的偏好設定,如果儲存空間中沒有任何資料,則會改為檢查系統偏好設定:

const storageKey = 'theme-preference'

const getColorPreference = () => {
  if (localStorage.getItem(storageKey))
    return localStorage.getItem(storageKey)
  else
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
}

接下來會剖析用於在本機儲存空間中設定使用者偏好的函式:

const setPreference = () => {
  localStorage.setItem(storageKey, theme.value)
  reflectPreference()
}

接著是用來使用偏好設定修改文件的函式。

const reflectPreference = () => {
  document.firstElementChild
    .setAttribute('data-theme', theme.value)

  document
    .querySelector('#theme-toggle')
    ?.setAttribute('aria-label', theme.value)
}

此時要特別注意 HTML 文件剖析狀態。由於 <head> 標記尚未完全剖析,瀏覽器還不瞭解「#theme-toggle」按鈕。不過,瀏覽器確實有 document.firstElementChild,也就是 <html> 標記。函式會嘗試設定這兩者,以便保持同步,但首次執行時只能設定 HTML 標記。querySelector 一開始不會找到任何項目,而選用鏈結運算子可確保在找不到該項目且嘗試叫用 setAttribute 函式時不會發生語法錯誤。

接著,系統會立即呼叫該函式 reflectPreference(),讓 HTML 文件套用 data-theme 屬性集:

reflectPreference()

按鈕仍需要屬性,因此請等待網頁載入事件,然後再安全地查詢、新增事件監聽器和設定屬性:

window.onload = () => {
  // set on load so screen readers can get the latest value on the button
  reflectPreference()

  // now this script can find and listen for clicks on the control
  document
    .querySelector('#theme-toggle')
    .addEventListener('click', onClick)
}

切換體驗

按下按鈕時,需要在 JavaScript 記憶體和文件中交換主題。您必須檢查目前的主題值,並決定其新狀態。設定新狀態後,請儲存並更新文件:

const onClick = () => {
  theme.value = theme.value === 'light'
    ? 'dark'
    : 'light'

  setPreference()
}

與系統同步

這個主題切換鈕的獨特之處,就是會隨著系統偏好設定的變更而同步。如果使用者在網頁和此元件顯示時變更系統偏好設定,主題切換鈕會變更以符合新的使用者偏好設定,就像使用者在系統切換時同時與主題切換鈕互動一樣。

您可以使用 JavaScript 和 matchMedia 事件來監聽媒體查詢的變更,實現這項功能:

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
變更 MacOS 系統偏好設定會變更主題切換器狀態

結論

既然你知道我如何做到,你會怎麼做呢? 🙂?

讓我們多方嘗試,瞭解在網路上建構應用程式的所有方式。請製作示範作品,並在推特上傳連結,我會將其加入下方的社群重混曲目錄!

社群重混作品