Context Provider for Dark Mode, Reduced Motion, High Contrast, etc.
Published on May 28, 2022
CSS media queries are powerful. We can create rich, accessible experiences for users that change along with their devices, needs, and preferences. But sometimes CSS won’t cut it — and we need access to media queries directly in our React component.
Here’s how I used React Context to accomplish exactly this!
We start by defining the media queries we want to access:
const mediaQueries = { xxsMax: '(max-width: 374px)', xs: '(min-width: 0)' xsMax: '(max-width: 479px)', sm: '(min-width: 480px)', smMax: '(max-width: 667px)', md: '(min-width: 668px)', mdMax: '(max-width: 991px)', lg: '(min-width: 992px)', lgMax: '(max-width: 1199px)', xl: '(min-width: 1200px)', portait: '(orientation: portrait)', darkMode: '(prefers-color-scheme: dark)', reduceMotion: '(prefers-reduced-motion: reduce)', highContrast: '(prefers-contrast: more)', };
Next, create your media-queries-context.tsx. This approach uses window.matchMedia and will monitor for any changes:
import React, { useState, useEffect, createContext, useContext, ReactNode, } from 'react'; import { mediaQueries } from './design-tokens'; const defaultValue = {}; interface MediaQueriesContextProps { xs?: boolean; xsMax?: boolean; sm?: boolean; smMax?: boolean; md?: boolean; mdMax?: boolean; lg?: boolean; lgMax?: boolean; xl?: boolean; portait?: boolean; darkMode?: boolean; highContrast?: boolean; reduceMotion?: boolean; } const MediaQueriesContext = createContext(defaultValue); interface MediaQueriesProps { children: ReactNode; } const MediaQueriesProvider = ({ children }: MediaQueriesProps) => { const [queryMatch, setQueryMatch] = useState({}); useEffect(() => { const mediaQueryLists = {}; const keys = Object.keys(mediaQueries); let isAttached = false; const handleQueryListener = () => { const updatedMatches = keys.reduce((acc, media) => { acc[media] = !!( mediaQueryLists[media]?.matches ); return acc; }, {}); setQueryMatch(updatedMatches); }; if (window?.matchMedia) { const matches = {}; keys.forEach((media) => { if (typeof mediaQueries[media] === 'string') { mediaQueryLists[media] = window.matchMedia(mediaQueries[media]); matches[media] = mediaQueryLists[media].matches; } else { matches[media] = false; } }); setQueryMatch(matches); isAttached = true; keys.forEach((media) => { if (typeof mediaQueries[media] === 'string') { mediaQueryLists[media].addListener(handleQueryListener); } }); } return () => { if (isAttached) { keys.forEach((media) => { if (typeof mediaQueries[media] === 'string') { mediaQueryLists[media].removeListener(handleQueryListener); } }); } }; }, []); return ( <MediaQueriesContext.Provider value={queryMatch}> {children} </MediaQueriesContext.Provider> ); }; const useMediaQueries = () => { const context = useContext<MediaQueriesContextProps>(MediaQueriesContext); if (context === defaultValue) { throw new Error('useMediaQueries must be used within MediaQueriesProvider'); } return context; }; export { useMediaQueries, MediaQueriesProvider };
Then, we’ll wrap the app (or component) in MediaQueriesProvider:
import { MediaQueriesProvider } from '../../common-components/media-queries-context'; const MyApp = ({ FooBarComponent, pageProps }) => <MediaQueriesProvider> <FooBarComponent {...pageProps} /> </MediaQueriesProvider> ); export default MyApp;
Finally — as long as our React components are children of this Provider — we can access these new values using the useMediaQueries hook:
import { useMediaQueries } from '../../../common-components/media-queries-context'; const FooBarComponent = () => { const { xs, xsMax, sm, smMax, md, mdMax, lg, lgMax, xl, portait, darkMode, highContrast, reduceMotion, } = useMediaQueries(); return <>...</>; }; export default FooBar;
Here’s to building fully accessible experiences! 🥂