2026年5月14日
React 與使用者偏好 OS 裡設過的那些選項
每一個現代作業系統都會在某個時刻問使用者
UI。深色還是淺色。高對比還是普通。動畫開還是關。從左到右還是從右到左。首選語言。使用者在系統設定裡選一次,從那一刻起,這台機器上每一個好好做出來的原生 App 都會尊重這個選擇。而你上線的 Web App 通常不會——它自己搞一個深色模式開關,自己用一個動畫函式庫,自己預設是英文,OS 偏好變成某個 issue 追蹤器裡五行字的備註。修起來不難,API 表面也很窄。瀏覽器透過 window.matchMedia 和 navigator.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 更進一步
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.darkvshtml.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 內容協商幹了幾十年的事
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-scheme、prefers-reduced-motion、prefers-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 | 訊號 | 什麼時候用…… |
|---|---|---|
usePreferredDark | OS 深色模式偏好 | 選主題要一個布林值 |
usePreferredColorScheme | 完整 light/dark/no-preference | 「跟隨系統」模式 UX 需要第三個值 |
useColorMode | 帶持久化的實際套用主題 | 你在搭主題系統本身 |
useReducedMotion | prefers-reduced-motion | 你把時長傳給動畫函式庫,或要擋下動效繁重的元件 |
usePreferredContrast | prefers-contrast | 你要出一個高對比變體 |
usePreferredLanguages | 完整 navigator.languages | 你要做區域協商,不只是偵測首選語言 |
useTextDirection | dir 屬性 | 你支援 RTL 語言並需要 JS 驅動翻轉 |
尊重使用者已經在 OS 裡設過的偏好,是你能上線的最便宜的可存取性升級。門檻低——回傳布林、切 className、傳時長——收益高。更多 hook 在 reactuse.com,如果你明天打開 prefers-reduced-motion,你的 App 不再把卡片到處甩,那今天鍵盤沒白敲。