2026年5月14日

React 與使用者偏好
OS 裡設過的那些選項

每一個現代作業系統都會在某個時刻問使用者

UI。深色還是淺色。高對比還是普通。動畫開還是關。從左到右還是從右到左。首選語言。使用者在系統設定裡選一次,從那一刻起,這台機器上每一個好好做出來的原生 App 都會尊重這個選擇。而你上線的 Web App 通常不會——它自己搞一個深色模式開關,自己用一個動畫函式庫,自己預設是英文,OS 偏好變成某個 issue 追蹤器裡五行字的備註。

修起來不難,API 表面也很窄。瀏覽器透過 window.matchMedianavigator.language 暴露這些 OS 偏好,任何一個現代 React 應用一個下午就能接好。問題不是能不能,而是接線程式碼住在跟所有 Web 特性一樣的 useEffect/useState/SSR 不一致的沼澤裡,所以永遠被擱置。ReactUse 為此提供了 7 個聚焦的 hook,它們一起涵蓋了真正重要的 4 個使用者偏好維度

、動效、對比度、語言區域。

這篇文章逐個走一遍——它回傳什麼、它藏了什麼 bug、最終的元件長什麼樣。最後把它們組合進一個 useAppearance() hook,一次性讀取這 4 個訊號。

1. usePreferredDark——開啟主題系統的那個布林值

最簡單的一個。usePreferredDark() 在使用者 OS 設為深色模式時回傳 true,否則 false。它是對 window.matchMedia('(prefers-color-scheme: dark)').matches 的輕量封裝,幫你處理兩件你本來要自己處理的事

(沒有 window)和即時更新(使用者可以在你的分頁打開的同時切 OS 開關,你應該回應)。

手寫版

import { useEffect, useState } from "react";

function ManualDark() {
  const [dark, setDark] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-color-scheme: dark)");
    setDark(mq.matches);
    const onChange = (e: MediaQueryListEvent) => setDark(e.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  return dark ? "dark" : "light";
}

這是對的,但初始 useState(false) 是猜的——對於 SSR 渲染的頁面,深色模式使用者第一次打開你網站時會產生 hydration 不一致。同樣的修復在真實程式碼庫裡要寫 5 次,而且預設值經常不一致。

ReactUse 版

import { usePreferredDark } from "@reactuses/core";

function Component() {
  const isDark = usePreferredDark();
  return <Theme name={isDark ? "dark" : "light"} />;
}

usePreferredDark 是布林進、布林出——丟哪都行,零設定。首次渲染回傳 SSR 安全的預設值;客戶端掛載後,真實的 matchMedia 值流進來,並隨著使用者切換保持同步。

2. usePreferredColorScheme——當「深色」不夠用時

prefers-color-scheme 有 3 個值,不是 2 個:'light''dark''no-preference'。大多數應用把第三個塌縮到前兩個裡的一個——這沒問題,直到你上線「跟隨系統」模式,然後發現有使用者顯式設了「無偏好」,而你的應用現在選錯了預設值。usePreferredColorScheme 回傳完整的字串。

import { usePreferredColorScheme } from "@reactuses/core";

function ThemeBadge() {
  const scheme = usePreferredColorScheme();
  // scheme: "light" | "dark" | "no-preference"
  return <span>System theme: {scheme}</span>;
}

三值形式最有用的地方是帶「系統」選項的主題選擇器:

type Choice = "light" | "dark" | "system";

function ThemePicker({ choice, onChange }: { choice: Choice; onChange: (c: Choice) => void }) {
  const scheme = usePreferredColorScheme();
  const effective =
    choice === "system"
      ? scheme === "dark"
        ? "dark"
        : "light"
      : choice;

  return (
    <fieldset>
      <legend>主題</legend>
      {(["light", "dark", "system"] as const).map((c) => (
        <label key={c}>
          <input
            type="radio"
            checked={choice === c}
            onChange={() => onChange(c)}
          />
          {c}
          {c === "system" && ` (目前 ${effective})`}
        </label>
      ))}
    </fieldset>
  );
}

可見標籤告訴使用者「系統」現在實際意味著什麼——一個很小的細節,能擋住最常見的深色模式困惑(「系統選項壞了;它給了我淺色」)。

3. useColorMode——帶持久化的主題狀態

usePreferredDark 回報 OS 偏好。useColorMode 更進一步

實際套用的主題。它把 OS 偏好作為預設值,允許使用者覆寫,把覆寫持久化到 localStorage,並把選中的模式寫到 <html> 的 class 或屬性上,這樣你的 CSS 就能切換。

useColorMode 才是你要的真實主題切換器:

import { useColorMode } from "@reactuses/core";

function ThemeToggle() {
  const [mode, setMode] = useColorMode();
  // mode: "light" | "dark" | "auto"

  return (
    <button onClick={() => setMode(mode === "dark" ? "light" : "dark")}>
      切到 {mode === "dark" ? "light" : "dark"}
    </button>
  );
}

一個 hook 你就拿到:

  • 初始值
    ,從 localStorage 讀;否則從 prefers-color-scheme
  • 'auto' 模式下即時追蹤 OS 變化
  • <html> 上 class 切換(html.dark vs html.light),CSS 裡不用任何 JS 條件
  • SSR 安全

自己實作主題系統的常見坑

,因為 OS 偏好是在 hydration 之後讀到的。useColorMode 在渲染時同步寫入解析後的模式,並在 React 接管樹之前從 localStorage 讀取持久化選擇,從而避開這個問題。配合 <head> 裡一段微小的內聯 <script> 在更早的時刻就把 class 設好,閃現就徹底沒了。

4. useReducedMotion——Web 上最便宜的可存取性勝利

prefers-reduced-motion 是 OS 級訊號,表示使用者希望螢幕上動得少一點。被視差弄暈的人、對大幅過渡有身體疼痛的前庭功能障礙使用者、螢幕閱讀器使用者(那本身就夠「動」)——他們都會打開這個。尊重它對你沒成本,贏得巨大善意。無視它是上線一個排斥使用者的 App 最快的方式之一。

import { useReducedMotion } from "@reactuses/core";
import { motion } from "framer-motion";

function FadeIn({ children }: { children: React.ReactNode }) {
  const reduced = useReducedMotion();

  return (
    <motion.div
      initial={reduced ? { opacity: 1 } : { opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: reduced ? 0 : 0.4 }}
    >
      {children}
    </motion.div>
  );
}

減弱動效開啟時,元件跳過 y 軸位移並用 0ms 過渡——內容仍然出現,只是沒了動畫。這是正確的模式

,移除運動本身。一個不帶動畫的 toast 仍然有用;一個根本不出現的 toast 是 bug。

useReducedMotion 回傳布林且回應 OS 設定,使用者中途切換偏好時動畫會立刻停下。

常見接入位置:

  • 頁面過渡
  • Modal/抽屜的進入退出
  • 數字遞增動畫
  • 視差/捲動驅動效果
  • 自動播放輪播(減弱動效時也要停掉 autoplay)

5. usePreferredContrast——被請求時增強邊界

prefers-contrast 是較新的媒體特性,回報使用者是否要求 OS 給更高或更低的對比度。值有 'more''less''no-preference''custom'。和減弱動效一樣,這是一個小族群但收益巨大——高對比模式對低視力使用者至關重要。

import { usePreferredContrast } from "@reactuses/core";

function Card({ children }: { children: React.ReactNode }) {
  const contrast = usePreferredContrast();
  const cls =
    contrast === "more"
      ? "card card--high-contrast"
      : "card";
  return <div className={cls}>{children}</div>;
}

高對比變體通常做 3 件事

、更強的色彩值(沒有粉彩/灰矇背景)、更清晰的焦點環。你不需要並行另一套主題——幾個針對性覆寫就夠:

.card--high-contrast {
  border: 2px solid currentColor;
  background: var(--surface);
  color: var(--text-strong);
}
.card--high-contrast :focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

usePreferredContrast 回傳原始字串,所以如果你對低對比使用者有事可做,可以獨立分支 'more''less'(大多數應用只匹配 'more',忽略其餘)。

6. usePreferredLanguages——超越 navigator.language

瀏覽器暴露 navigator.languages——使用者首選區域的有序陣列,例如 ["en-US", "zh-CN", "ja-JP"]。大多數應用只讀 navigator.language(第一項),丟掉了訊號

["zh-CN", "en-US"] 的使用者想要中文優先、英文兜底,而不是你猜的隨便什麼。

usePreferredLanguages 回傳完整陣列,並在使用者改瀏覽器語言偏好時保持同步:

import { usePreferredLanguages } from "@reactuses/core";

const SUPPORTED = ["en", "zh-Hans", "zh-Hant", "ja", "es"] as const;

function pickLocale(preferred: readonly string[]): string {
  for (const lang of preferred) {
    const base = lang.toLowerCase();
    if (SUPPORTED.includes(base as (typeof SUPPORTED)[number])) return base;
    const region = base.split("-")[0];
    const match = SUPPORTED.find((s) => s.toLowerCase().startsWith(region));
    if (match) return match;
  }
  return "en";
}

function LocaleAuto() {
  const preferred = usePreferredLanguages();
  const locale = pickLocale(preferred);
  return <App locale={locale} />;
}

協商邏輯做的事就是伺服器端 Accept-Language 內容協商幹了幾十年的事

,優雅 fallback,最後兜底英文。相對 navigator.language 的勝利是實打實的
"de-CH"、次選 "en" 的使用者如果你不支援德語,就會落到英文版,而不是看到一個翻譯了一半的 UI。

7. useTextDirection——RTL 不只是 CSS

從右到左的語言(阿拉伯語、希伯來語、波斯語)把整個頁面的閱讀方向翻轉。CSS 透過邏輯屬性(margin-inline-start 而不是 margin-left)處理大部分,但真正的 RTL 實作還需要 JS 驅動的行為翻轉

、輪播的捲動吸附、動畫方向、拖拽消除方向。

useTextDirection 讀取(也可寫入)目標元素的 dir 屬性:

import { useEffect } from "react";
import { useTextDirection } from "@reactuses/core";

function App({ locale }: { locale: string }) {
  const [dir, setDir] = useTextDirection();

  useEffect(() => {
    setDir(isRtl(locale) ? "rtl" : "ltr");
  }, [locale, setDir]);

  return (
    <main>
      <Carousel direction={dir === "rtl" ? "leftward" : "rightward"} />
      <KeyboardHandler arrowsFlipped={dir === "rtl"} />
    </main>
  );
}

function isRtl(locale: string): boolean {
  return ["ar", "he", "fa", "ur"].some((p) => locale.startsWith(p));
}

預設 hook 讀 <html dir="...">,但它可以指向任意元素——對那些需要獨立於外圍頁面感知 RTL 的嵌入式 widget 很有用。

拼起來

大多數應用想在根節點一次性讀這 4 個訊號——顏色、動效、對比度、方向——然後透過 context 往下傳。單一衍生 hook 比每個元件裡呼叫 4 次更乾淨:

import {
  usePreferredDark,
  usePreferredContrast,
  useReducedMotion,
  usePreferredLanguages,
  useTextDirection,
} from "@reactuses/core";

export type Appearance = {
  isDark: boolean;
  highContrast: boolean;
  reducedMotion: boolean;
  locale: string;
  dir: "ltr" | "rtl";
};

export function useAppearance(): Appearance {
  const isDark = usePreferredDark();
  const contrast = usePreferredContrast();
  const reducedMotion = useReducedMotion();
  const preferred = usePreferredLanguages();
  const [dir] = useTextDirection();

  const locale = pickLocale(preferred);

  return {
    isDark,
    highContrast: contrast === "more",
    reducedMotion,
    locale,
    dir: dir === "rtl" ? "rtl" : "ltr",
  };
}

在根節點用一次:

function App() {
  const appearance = useAppearance();

  return (
    <AppearanceContext.Provider value={appearance}>
      <html
        className={`${appearance.isDark ? "dark" : "light"} ${
          appearance.highContrast ? "contrast-more" : ""
        } ${appearance.reducedMotion ? "motion-reduce" : ""}`}
        dir={appearance.dir}
        lang={appearance.locale}
      >
        <Routes />
      </html>
    </AppearanceContext.Provider>
  );
}

<html> 元素現在反映使用者設過的每一個偏好:class 給主題/對比度/動效變體,dir 給方向,lang 給區域。任何想根據偏好分支的 CSS 規則用一個屬性選擇器就行,任何需要原始訊號的元件可以從 AppearanceContext 拿,不必再次訂閱 matchMedia

能用 CSS 就用 CSS,JS 留給真需要時

合理的疑問

JS?prefers-color-schemeprefers-reduced-motionprefers-contrast 都是 CSS 媒體特性,可以在樣式表裡處理:

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

純視覺變化,CSS 贏。這些 JS hook 真正賺到飯的場景:

  • 偏好驅動行為而不僅是外觀(輪播自動播放、傳給動畫函式庫的時長數值)
  • 偏好決定掛載哪個元件(<Parallax /> vs <StaticImage />)
  • 偏好影響一個住在 React state 裡的衍生值(區域協商、主題持久化)
  • 你想要一個使用者開關來覆寫 OS 偏好(useColorMode'auto' vs 'light' vs 'dark')

經驗法則

CSS,JS 真需要知道時再拿這些 hook。

總結

Hook訊號什麼時候用……
usePreferredDarkOS 深色模式偏好選主題要一個布林值
usePreferredColorScheme完整 light/dark/no-preference「跟隨系統」模式 UX 需要第三個值
useColorMode帶持久化的實際套用主題你在搭主題系統本身
useReducedMotionprefers-reduced-motion你把時長傳給動畫函式庫,或要擋下動效繁重的元件
usePreferredContrastprefers-contrast你要出一個高對比變體
usePreferredLanguages完整 navigator.languages你要做區域協商,不只是偵測首選語言
useTextDirectiondir 屬性你支援 RTL 語言並需要 JS 驅動翻轉

尊重使用者已經在 OS 裡設過的偏好,是你能上線的最便宜的可存取性升級。門檻低——回傳布林、切 className、傳時長——收益高。更多 hook 在 reactuse.com,如果你明天打開 prefers-reduced-motion,你的 App 不再把卡片到處甩,那今天鍵盤沒白敲。