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 不再把卡片到处甩,那今天键盘没白敲。