/** * useMediaQuery Hook * Tracks CSS media query state for responsive design and feature detection * * Features: * - Real-time media query matching * - Support for single or multiple queries * - Automatic listener cleanup * - SSR-safe with initial state * - Useful for responsive layouts and feature detection * * @example * // Single query * const isMobile = useMediaQuery('(max-width: 767px)') * const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)') * const isLandscape = useMediaQuery('(orientation: landscape)') * * return ( *
* {isMobile && } * {!isMobile && } *
* ) * * @example * // Multiple queries * const { mobile, tablet, desktop } = useMediaQuery({ * mobile: '(max-width: 639px)', * tablet: '(min-width: 640px) and (max-width: 1023px)', * desktop: '(min-width: 1024px)', * }) * * if (mobile) return * if (tablet) return * return * * @example * // Accessibility features * const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') * const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)') * * if (prefersReducedMotion) { * // Disable animations * } */ import { useState, useEffect, useCallback } from 'react' export type UseMediaQueryInput = string | Record export type UseMediaQueryReturn = T extends string ? boolean : Record export function useMediaQuery(query: T): UseMediaQueryReturn { // Handle single string query if (typeof query === 'string') { const [matches, setMatches] = useState(() => { // SSR-safe check if (typeof window === 'undefined') { return false } try { return window.matchMedia(query).matches } catch { return false } }) useEffect(() => { if (typeof window === 'undefined') return try { const mediaQueryList = window.matchMedia(query) // Update initial state setMatches(mediaQueryList.matches) // Create listener - supports both new and old API const handleChange = (e: MediaQueryListEvent | Event) => { if (e instanceof MediaQueryListEvent) { setMatches(e.matches) } else if ('matches' in e.target!) { setMatches((e.target as MediaQueryList).matches) } } // Use addEventListener (modern API) mediaQueryList.addEventListener('change', handleChange) return () => { mediaQueryList.removeEventListener('change', handleChange) } } catch { // Handle invalid queries return undefined } }, [query]) return matches as UseMediaQueryReturn } // Handle multiple queries as object const [queryMatches, setQueryMatches] = useState>(() => { const initial: Record = {} if (typeof window === 'undefined') { Object.keys(query).forEach((key) => { initial[key] = false }) return initial } try { Object.entries(query).forEach(([key, mediaQuery]) => { initial[key] = window.matchMedia(mediaQuery).matches }) } catch { Object.keys(query).forEach((key) => { initial[key] = false }) } return initial }) useEffect(() => { if (typeof window === 'undefined') return const mediaQueryLists: Array<{ key: string; mql: MediaQueryList }> = [] try { Object.entries(query).forEach(([key, mediaQuery]) => { const mql = window.matchMedia(mediaQuery) mediaQueryLists.push({ key, mql }) // Update initial state setQueryMatches((prev) => ({ ...prev, [key]: mql.matches, })) }) } catch { return undefined } const handleChange = (key: string) => (e: MediaQueryListEvent | Event) => { if (e instanceof MediaQueryListEvent) { setQueryMatches((prev) => ({ ...prev, [key]: e.matches, })) } else if ('matches' in e.target!) { setQueryMatches((prev) => ({ ...prev, [key]: (e.target as MediaQueryList).matches, })) } } mediaQueryLists.forEach(({ key, mql }) => { const handler = handleChange(key) mql.addEventListener('change', handler) }) return () => { mediaQueryLists.forEach(({ mql, key }) => { mql.removeEventListener('change', handleChange(key)) }) } }, [query]) return queryMatches as UseMediaQueryReturn }