Building Accessible React Components with Hooks
Accessibility is not a checklist you run through before launch. It is a design constraint that shapes how your application behaves from the first line of code. When we talk about accessibility in React, most developers think of ARIA attributes, semantic HTML, and screen reader support. Those matter. But there is an entire category of accessibility that gets far less attention: respecting the preferences your users have already set at the operating system level.
Every major operating system lets users configure preferences like reduced motion, high contrast, dark mode, and text direction. These are not cosmetic choices. A user who enables "reduce motion" may experience vestibular disorders that make animated transitions physically uncomfortable. A user who enables high contrast may have low vision. When your React application ignores these signals, it is not just a missed feature — it is a barrier.
This article shows you how to detect and respond to these OS-level preferences in React using hooks from ReactUse. We will cover reduced motion, contrast preferences, color scheme detection, focus management, and text direction — then bring everything together in a practical component.
The Problem with Manual Media Query Listeners
The browser exposes OS-level preferences through CSS media queries like prefers-reduced-motion, prefers-contrast, and prefers-color-scheme. You can read these in JavaScript using window.matchMedia. Here is what the manual approach looks like:
import { useState, useEffect } from "react";
function useManualReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReducedMotion(mediaQuery.matches);
const handler = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, []);
return prefersReducedMotion;
}
This works, but it has problems. You need to handle SSR (where window does not exist), manage event listener cleanup, and repeat this pattern for every media query you want to track. Multiply that across reduced motion, contrast, color scheme, and other queries, and you end up with a lot of boilerplate that is easy to get wrong.
ReactUse provides hooks that encapsulate this pattern with correct SSR handling, proper cleanup, and real-time updates when the user changes their system preferences.
useReducedMotion: Respecting Motion Preferences
The useReducedMotion hook detects whether the user has enabled the "reduce motion" setting on their device. This is one of the most impactful accessibility hooks you can use, because motion can cause real physical discomfort for users with vestibular disorders.
import { useReducedMotion } from "@reactuses/core";
function AnimatedCard({ children }: { children: React.ReactNode }) {
const prefersReducedMotion = useReducedMotion();
return (
<div
style={{
transition: prefersReducedMotion
? "none"
: "transform 0.3s ease, opacity 0.3s ease",
animation: prefersReducedMotion ? "none" : "fadeIn 0.5s ease-in",
}}
>
{children}
</div>
);
}
The key insight here is not just to disable animations — it is to provide an equivalent experience without motion. A card that fades in over 500ms for most users should simply appear instantly for users who prefer reduced motion. The content is the same; only the delivery changes.
You can also use this hook to swap between animation strategies:
import { useReducedMotion } from "@reactuses/core";
function PageTransition({ children }: { children: React.ReactNode }) {
const prefersReducedMotion = useReducedMotion();
if (prefersReducedMotion) {
// Instant transition — no motion, but still a visual change
return <div style={{ opacity: 1 }}>{children}</div>;
}
// Full slide-in animation for users who haven't opted out
return (
<div
style={{
animation: "slideInFromRight 0.4s ease-out",
}}
>
{children}
</div>
);
}
usePreferredContrast: Adapting to Contrast Needs
The usePreferredContrast hook reads the prefers-contrast media query, which tells you whether the user wants more contrast, less contrast, or has no preference. This is critical for users with low vision.
import { usePreferredContrast } from "@reactuses/core";
function ThemedButton({ children, onClick }: {
children: React.ReactNode;
onClick: () => void;
}) {
const contrast = usePreferredContrast();
const getButtonStyles = () => {
switch (contrast) {
case "more":
return {
backgroundColor: "#000000",
color: "#FFFFFF",
border: "3px solid #FFFFFF",
fontWeight: 700 as const,
};
case "less":
return {
backgroundColor: "#E8E8E8",
color: "#333333",
border: "1px solid #CCCCCC",
fontWeight: 400 as const,
};
default:
return {
backgroundColor: "#3B82F6",
color: "#FFFFFF",
border: "2px solid transparent",
fontWeight: 500 as const,
};
}
};
return (
<button onClick={onClick} style={getButtonStyles()}>
{children}
</button>
);
}
When the user has requested higher contrast, you should increase the difference between foreground and background colors, use heavier font weights, and make borders more visible. When they request less contrast, soften the visual intensity. The default branch handles users who have not set a preference.
usePreferredColorScheme: System Theme Detection
The usePreferredColorScheme hook tells you whether the user's operating system is set to light mode, dark mode, or has no preference. This is the foundation for building theme-aware components.
import { usePreferredColorScheme } from "@reactuses/core";
function AdaptiveCard({ title, body }: { title: string; body: string }) {
const colorScheme = usePreferredColorScheme();
const isDark = colorScheme === "dark";
return (
<div
style={{
backgroundColor: isDark ? "#1E293B" : "#FFFFFF",
color: isDark ? "#E2E8F0" : "#1E293B",
border: `1px solid ${isDark ? "#334155" : "#E2E8F0"}`,
borderRadius: "8px",
padding: "24px",
}}
>
<h3 style={{ marginTop: 0 }}>{title}</h3>
<p>{body}</p>
</div>
);
}
For a simpler boolean check, ReactUse also provides usePreferredDark, which returns true when the user prefers a dark color scheme. And if you need a full dark mode toggle that persists the user's choice, useDarkMode provides that out of the box.
For even more granular control over media queries, useMediaQuery lets you subscribe to any CSS media query string and get live updates.
useFocus: Keyboard Navigation and Focus Management
Keyboard navigation is a core accessibility requirement. Users who cannot use a mouse rely on the Tab key to move between interactive elements. The useFocus hook gives you programmatic control over focus, which is essential for modal dialogs, dropdown menus, and dynamic content.
import { useRef } from "react";
import { useFocus } from "@reactuses/core";
function SearchBar() {
const inputRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useFocus(inputRef);
return (
<div>
<input
ref={inputRef}
type="search"
placeholder="Search..."
style={{
outline: focused ? "2px solid #3B82F6" : "1px solid #D1D5DB",
padding: "8px 12px",
borderRadius: "6px",
width: "100%",
}}
/>
<button onClick={() => setFocused(true)}>
Focus Search (Ctrl+K)
</button>
</div>
);
}
The hook returns both the current focus state and a setter function. You can use the focus state to apply visual indicators (beyond the browser default) and use the setter to programmatically move focus — for example, when a modal opens or when a keyboard shortcut is triggered.
Pairing this with useActiveElement lets you track which element currently has focus across your entire application, which is useful for building focus traps and skip-navigation links.
useTextDirection: RTL and LTR Support
Internationalization and accessibility overlap heavily. The useTextDirection hook detects and manages the text direction of your document, supporting both left-to-right (LTR) and right-to-left (RTL) layouts.
import { useTextDirection } from "@reactuses/core";
function NavigationMenu() {
const [dir, setDir] = useTextDirection();
return (
<nav
style={{
display: "flex",
flexDirection: dir === "rtl" ? "row-reverse" : "row",
gap: "16px",
padding: "12px 24px",
}}
>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
<button onClick={() => setDir(dir === "rtl" ? "ltr" : "rtl")}>
Toggle Direction
</button>
</nav>
);
}
RTL support affects more than text alignment. Navigation order, icon placement, and margin/padding directions all need to flip. By using useTextDirection as the source of truth, you can build layout logic that adapts automatically.
Putting It All Together: An Accessible Notification Component
Here is a practical example that combines multiple accessibility hooks into a single component — a notification toast that respects motion preferences, adapts to contrast settings, follows the system color scheme, and manages focus correctly:
import { useRef, useEffect } from "react";
import {
useReducedMotion,
usePreferredContrast,
usePreferredColorScheme,
useFocus,
} from "@reactuses/core";
interface NotificationProps {
message: string;
type: "success" | "error" | "info";
visible: boolean;
onDismiss: () => void;
}
function AccessibleNotification({
message,
type,
visible,
onDismiss,
}: NotificationProps) {
const prefersReducedMotion = useReducedMotion();
const contrast = usePreferredContrast();
const colorScheme = usePreferredColorScheme();
const dismissRef = useRef<HTMLButtonElement>(null);
const [, setFocused] = useFocus(dismissRef);
const isDark = colorScheme === "dark";
const isHighContrast = contrast === "more";
// Move focus to the dismiss button when notification appears
useEffect(() => {
if (visible) {
setFocused(true);
}
}, [visible, setFocused]);
if (!visible) return null;
const colors = {
success: {
bg: isDark ? "#064E3B" : "#ECFDF5",
border: isHighContrast ? "#FFFFFF" : isDark ? "#10B981" : "#6EE7B7",
text: isDark ? "#A7F3D0" : "#065F46",
},
error: {
bg: isDark ? "#7F1D1D" : "#FEF2F2",
border: isHighContrast ? "#FFFFFF" : isDark ? "#EF4444" : "#FCA5A5",
text: isDark ? "#FECACA" : "#991B1B",
},
info: {
bg: isDark ? "#1E3A5F" : "#EFF6FF",
border: isHighContrast ? "#FFFFFF" : isDark ? "#3B82F6" : "#93C5FD",
text: isDark ? "#BFDBFE" : "#1E40AF",
},
};
const scheme = colors[type];
return (
<div
role="alert"
aria-live="assertive"
style={{
position: "fixed",
top: "16px",
right: "16px",
backgroundColor: scheme.bg,
color: scheme.text,
border: `${isHighContrast ? "3px" : "1px"} solid ${scheme.border}`,
borderRadius: "8px",
padding: "16px 20px",
maxWidth: "400px",
display: "flex",
alignItems: "center",
gap: "12px",
fontWeight: isHighContrast ? 700 : 400,
// Respect motion preferences
animation: prefersReducedMotion ? "none" : "slideIn 0.3s ease-out",
transition: prefersReducedMotion ? "none" : "opacity 0.2s ease",
}}
>
<span style={{ flex: 1 }}>{message}</span>
<button
ref={dismissRef}
onClick={onDismiss}
aria-label="Dismiss notification"
style={{
background: "none",
border: `1px solid ${scheme.text}`,
color: scheme.text,
cursor: "pointer",
borderRadius: "4px",
padding: "4px 8px",
fontWeight: isHighContrast ? 700 : 500,
}}
>
Dismiss
</button>
</div>
);
}
This component demonstrates several accessibility principles working together:
-
role="alert"andaria-live="assertive"ensure screen readers announce the notification immediately. -
useReducedMotiondisables the slide-in animation for users who prefer reduced motion. -
usePreferredContrastincreases border width and font weight for users who need more contrast. -
usePreferredColorSchemeadapts all colors to the user's light or dark theme. -
useFocusmoves keyboard focus to the dismiss button so the user can act on the notification without reaching for the mouse.
Why Hooks Are the Right Abstraction for Accessibility
Hooks are composable. Each accessibility concern is encapsulated in its own hook, and you combine them as needed. A simple button might only use usePreferredContrast. A complex modal might use all five hooks we covered. The hooks do not know about each other, which means you can adopt them incrementally without refactoring existing code.
Hooks also respond to changes in real time. If a user switches from light to dark mode while your application is open, the hooks update and your components re-render with the new preference. This is difficult to achieve with CSS-only solutions that rely on static class names.
Installation
Install ReactUse via your package manager:
npm install @reactuses/core
Then import the hooks you need:
import {
useReducedMotion,
usePreferredContrast,
usePreferredColorScheme,
useFocus,
useTextDirection,
} from "@reactuses/core";
Related Hooks
-
useReducedMotion— detect theprefers-reduced-motionpreference -
usePreferredContrast— detect theprefers-contrastpreference -
usePreferredColorScheme— detectprefers-color-scheme(light, dark, or no preference) -
usePreferredDark— boolean shorthand for dark mode detection -
useDarkMode— full dark mode toggle with persistence -
useMediaQuery— subscribe to any CSS media query -
useFocus— programmatic focus management -
useActiveElement— track the currently focused element -
useTextDirection— detect and control LTR/RTL text direction
ReactUse provides 100+ hooks for React. Explore them all →
Originally published on ReactUse Blog
Top comments (0)