1import { useCallback, useSyncExternalStore } from "react";
2
3const BREAKPOINTS = {
4 "2xl": 1536,
5 "3xl": 1600,
6 "4xl": 2000,
7 lg: 1024,
8 md: 768,
9 sm: 640,
10 xl: 1280,
11} as const;
12
13type Breakpoint = keyof typeof BREAKPOINTS;
14
15type BreakpointQuery = Breakpoint | `max-${Breakpoint}` | `${Breakpoint}:max-${Breakpoint}`;
16
17function resolveMin(value: Breakpoint | number): string {
18 const px = typeof value === "number" ? value : BREAKPOINTS[value];
19 return `(min-width: ${px}px)`;
20}
21
22function resolveMax(value: Breakpoint | number): string {
23 const px = typeof value === "number" ? value : BREAKPOINTS[value];
24 return `(max-width: ${px - 1}px)`;
25}
26
27function parseQuery(query: BreakpointQuery | MediaQueryInput | (string & {})): string {
28 if (typeof query !== "string") {
29 const parts: string[] = [];
30 if (query.min != null) parts.push(resolveMin(query.min));
31 if (query.max != null) parts.push(resolveMax(query.max));
32 if (query.pointer === "coarse") parts.push("(pointer: coarse)");
33 if (query.pointer === "fine") parts.push("(pointer: fine)");
34 if (parts.length === 0) return "(min-width: 0px)";
35 return parts.join(" and ");
36 }
37
38 if (query.startsWith("(")) return query;
39
40 const parts: string[] = [];
41 for (const segment of query.split(":")) {
42 if (segment.startsWith("max-")) {
43 const bp = segment.slice(4);
44 if (bp in BREAKPOINTS) parts.push(resolveMax(bp as Breakpoint));
45 } else if (segment in BREAKPOINTS) {
46 parts.push(resolveMin(segment as Breakpoint));
47 }
48 }
49
50 return parts.length > 0 ? parts.join(" and ") : query;
51}
52
53function getServerSnapshot(): boolean {
54 return false;
55}
56
57export type MediaQueryInput = {
58 min?: Breakpoint | number;
59 max?: Breakpoint | number;
60 /** Touch-like input (finger). Use "fine" for mouse/trackpad. */
61 pointer?: "coarse" | "fine";
62};
63
64export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string & {})): boolean {
65 const mediaQuery = parseQuery(query);
66
67 const subscribe = useCallback(
68 (callback: () => void) => {
69 if (typeof window === "undefined") return () => {};
70 const mql = window.matchMedia(mediaQuery);
71 mql.addEventListener("change", callback);
72 return () => mql.removeEventListener("change", callback);
73 },
74 [mediaQuery],
75 );
76
77 const getSnapshot = useCallback(() => {
78 if (typeof window === "undefined") return false;
79 return window.matchMedia(mediaQuery).matches;
80 }, [mediaQuery]);
81
82 return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
83}
84
85export function useIsMobile(): boolean {
86 return useMediaQuery("max-md");
87}