建立主題切換元件

關於如何建構自動調整式主題切換元件的基本總覽。

在這篇文章中,我想分享一些想法,說明如何建構深色和淺色主題切換元件。立即試用

示範按鈕大小放大,方便使用者查看

如果你偏好使用影片,也可以觀看這篇 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 已取得的內容宣告「淺色」或「深色」。

「可擴充向量圖形」(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 方便形狀的圓形和線條組成。<circle> 的中心點是將 cxcy 屬性設為 12 (該屬性是可視區域大小的一半 (24),然後指定 6 的半徑 (r),以設定大小。

<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>

此外,遮罩屬性會指向可擴充向量圖形元素的 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>

這時會設定每一行的筆劃,而不是「填入」的值為 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 時可使用的最小樣式規則。因此適用於避免網路擾亂的防禦樣式。

版面配置

主題切換元件的介面區域較小,因此不需要使用格線或 Flexbox 來設定版面配置。而是改用 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 也需要一些樣式。可擴充向量圖形應符合按鈕大小。如要達到視覺柔和的效果,請將線條末端延伸至整行:

.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: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

請注意,深色主題沒有任何色彩變更或轉場效果。父項按鈕元件擁有顏色,而顏色則在深色和淺色的情況下已自動調整。轉換資訊應在使用者的動態偏好設定媒體查詢後方。

動畫

按鈕應可正常運作且有狀態,但此時不會有轉場效果。以下各節的內容涵蓋定義「方式」與「內容」轉換。

分享媒體查詢和匯入加/減速設定

為輕鬆將轉場效果和動畫放在使用者的作業系統動作偏好設定後方,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 加/減速功能,請匯入開放式屬性加/減速部分:

@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: 1) {
        transform: translateX(0);
        cx: 17;
        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 系統偏好設定會變更主題切換狀態

結論

現在既然你已經知道我怎麼做,你會怎麼做‽ 🙂?

讓我們帶您更多元的方法,並瞭解運用網路打造網站的所有方式。 請建立示範並透過 Twitter 推文連結,我就能將這項工具新增至下方的「社群重混」部分!

社群重混作品