Writing

深淺主題 (Theming)

文章發表於

深色與淺色主題近年來幾乎已成為網站的標配,從個人部落格到客戶端應用(例如:GitHub),皆有提供切換深淺色主題的功能,讓用戶能依照個人喜好自由選擇介面風格。

而要實現網站主題的無痛切換,我認為最重要的原則是:在開發時,以〈設計標籤〉作為 CSS 參數的基石。在先前的章節〈從設計標籤到 CSS〉 中,我們已介紹過其帶來的好處。如果產品色碼未被納入設計標籤 (Design Token) 內,就必須在應用層的 CSS 中額外處理,這在日後更改顏色時會增添不必要的麻煩。

回顧

設計標籤 (Design Tokens)

〈設計標籤〉 一文中,我們介紹了設計標籤的基本概念。其中,透過 System Tokenalias.json)的深淺主題配置,分別對應到不同的 Reference Tokenbase.json),這樣的設計使我們能根據不同條件,動態地將同一個 System Token 解析為不同的值。

標準化 CSS (Normalized CSS)

接著,在 Style Dictionary 建構設計標籤的過程中,其會將 :root 中的變數根據主題拆分為 lightdark,並整理到 normalized.cssSource Code)。並將這份 CSS 檔放在 <head> 內讓網頁載入時引入其檔案。在透過 data-theme 屬性來控制使用哪一組主題色。

Style Dictionary 的實際輸出結構:

/* 建議的結構 - 優先使用系統偏好設定 */
/* 深色主題(預設) */
:root {
--your-design-system-sys-color-primary: #D0BCFF;
--your-design-system-sys-color-background: #141218;
--your-design-system-sys-color-on-background: #E6E0E9;
/* ... 其他深色主題 tokens */
}
/* 淺色主題 */
html[data-theme='light'], .your-design-system-light {
--your-design-system-sys-color-primary: #6750A4;
--your-design-system-sys-color-background: #FEF7FF;
--your-design-system-sys-color-on-background: #1D1B20;
/* ... 其他淺色主題 tokens */
}

以下我們將會介紹如何實作讓使用者可以切換深淺色模式的功能,也就是本篇要介紹的 useTheme Hook。

useTheme

描述

useTheme 的核心功能是控制 data-theme 屬性的值,同時提供主題狀態管理和持久化儲存功能。

API

理想上我們希望當使用 useTheme 時,可以讓我們更新 data-theme 以及取得當前的 theme 狀態。

import { useTheme } from '@tocino-ui/core/hooks'
export default () => {
const { theme, resolvedTheme, toggleTheme, setTheme } = useTheme()
return (
<div>
<span>Current Theme: {theme}</span>
<span>Resolved Theme: {resolvedTheme}</span>
<button onClick={toggleTheme}>Toggle Theme</button>
<button onClick={() => setTheme('system')}>Use System Theme</button>
</div>
)
}

參數

名稱型別初始值描述
initialTheme'light' | 'dark' | 'system''system'初始的主題

回傳 API

名稱型別描述
theme'light' | 'dark' | 'system'當前的主題模式
resolvedTheme'light' | 'dark'實際渲染的主題
toggleTheme() => void在 light/dark 間切換
setTheme(theme: 'light' | 'dark' | 'system') => void設定特定主題

實作

請點擊「展開程式碼」以檢視程式碼。
import React, { useEffect, useState, useCallback, useMemo } from 'react'
import { Button } from '@tocino-ui/button'

/**
 * ====== CONSTANTS ======
 */
const THEME_STORAGE_KEY = 'your-design-system-theme'
const THEME_MODES = {
  LIGHT: 'light',
  DARK: 'dark',
  SYSTEM: 'system',
}

/**
 * ====== THEME MANAGER ======
 */
const themeManager = {
  getStoredTheme: () => {
    if (typeof window === 'undefined') return THEME_MODES.SYSTEM
    
    try {
      const stored = localStorage.getItem(THEME_STORAGE_KEY)
      if (stored && Object.values(THEME_MODES).includes(stored)) {
        return stored
      }
    } catch (error) {
      console.warn('無法讀取主題設定:', error)
    }
    
    return THEME_MODES.SYSTEM
  },

  setStoredTheme: (theme) => {
    if (typeof window === 'undefined') return
    
    try {
      localStorage.setItem(THEME_STORAGE_KEY, theme)
    } catch (error) {
      console.warn('無法儲存主題設定:', error)
    }
  },

  getSystemTheme: () => {
    if (typeof window === 'undefined') return THEME_MODES.DARK
    
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? THEME_MODES.DARK
      : THEME_MODES.LIGHT
  },

  resolveTheme: (theme) => {
    return theme === THEME_MODES.SYSTEM ? themeManager.getSystemTheme() : theme
  },

  applyTheme: (resolvedTheme) => {
    if (typeof document === 'undefined') return
    
    document.documentElement.setAttribute('data-theme', resolvedTheme)
    
    // 設定 color-scheme 以改善瀏覽器原生元件的顯示
    document.documentElement.style.colorScheme = resolvedTheme
  },
}

/**
 * ====== useTheme Hook ======
 */
function useTheme(initialTheme = THEME_MODES.SYSTEM) {
  // 初始化主題狀態
  const [theme, setThemeState] = useState(() => {
    if (typeof window === 'undefined') return initialTheme
    return themeManager.getStoredTheme()
  })

  // 計算實際渲染的主題
  const resolvedTheme = useMemo(() => {
    return themeManager.resolveTheme(theme)
  }, [theme])

  // 監聽系統主題變化
  useEffect(() => {
    if (typeof window === 'undefined' || theme !== THEME_MODES.SYSTEM) return

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
    
    const handleSystemThemeChange = () => {
      // 當前是 system 模式時,強制重新渲染以更新 resolvedTheme
      setThemeState(THEME_MODES.SYSTEM)
    }

    mediaQuery.addEventListener('change', handleSystemThemeChange)
    return () => mediaQuery.removeEventListener('change', handleSystemThemeChange)
  }, [theme])

  // 套用主題到 DOM
  useEffect(() => {
    themeManager.applyTheme(resolvedTheme)
  }, [resolvedTheme])

  // 持久化主題設定
  useEffect(() => {
    themeManager.setStoredTheme(theme)
  }, [theme])

  // 切換主題函數
  const toggleTheme = useCallback(() => {
    setThemeState(prev => {
      if (prev === THEME_MODES.SYSTEM) {
        // system 模式下,根據當前系統主題決定切換方向
        const currentSystemTheme = themeManager.getSystemTheme()
        return currentSystemTheme === THEME_MODES.DARK 
          ? THEME_MODES.LIGHT 
          : THEME_MODES.DARK
      }
      return prev === THEME_MODES.DARK ? THEME_MODES.LIGHT : THEME_MODES.DARK
    })
  }, [])

  // 設定主題函數
  const setTheme = useCallback((newTheme) => {
    setThemeState(newTheme)
  }, [])

  return useMemo(
    () => ({
      theme,
      resolvedTheme,
      toggleTheme,
      setTheme,
    }),
    [theme, resolvedTheme, toggleTheme, setTheme]
  )
}

/**
 * ====== 使用範例 ======
 */
export default function ThemeExample() {
  const { theme, resolvedTheme, toggleTheme, setTheme } = useTheme()

  return (
    <div className="container">
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/@tocino-ui/design-tokens/dist/normalize/normalize.css"
      />
      
      <h2 className="header">
        Current Theme: {theme}
        {theme === 'system' && ` (resolved: ${resolvedTheme})`}
      </h2>
      
      <div className="button-group">
        <Button 
          variant={theme === 'light' ? 'filled' : 'outline'} 
          onClick={() => setTheme('light')}
        >
          Light Mode
        </Button>
        
        <Button 
          variant={theme === 'dark' ? 'filled' : 'outline'} 
          onClick={() => setTheme('dark')}
        >
          Dark Mode
        </Button>
        
        <Button 
          variant={theme === 'system' ? 'filled' : 'outline'} 
          onClick={() => setTheme('system')}
        >
          System Mode
        </Button>
      </div>
      
      <div className="info">
        <p>試著切換你的作業系統主題設定,看看 System Mode 的變化!</p>
        <p>主題設定會自動儲存在 localStorage 中。</p>
      </div>
    </div>
  )
}

可以看到上面引入了我們先前做的 Button 組件,並且透過 useTheme 去控制 data-theme 的值,這樣就可以讓設計系統能夠支援切換深淺色主題。

如果您喜歡這篇文章,請點擊下方按鈕分享給更多人,這將是對筆者創作的最大支持和鼓勵。
Buy me a coffee