diff --git a/src/internal/breakpoints.ts b/src/internal/breakpoints.ts
new file mode 100644
index 0000000..54f3c96
--- /dev/null
+++ b/src/internal/breakpoints.ts
@@ -0,0 +1,16 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export type Breakpoint = 'default' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl';
+
+const BREAKPOINT_MAPPING: [Breakpoint, number][] = [
+ ['xl', 1840],
+ ['l', 1320],
+ ['m', 1120],
+ ['s', 912],
+ ['xs', 688],
+ ['xxs', 465],
+ ['default', -1],
+];
+
+export const mobileBreakpoint = BREAKPOINT_MAPPING.filter(b => b[0] === 'xs')[0][1];
diff --git a/src/internal/use-mobile/_tests__/use-mobile.test.tsx b/src/internal/use-mobile/_tests__/use-mobile.test.tsx
new file mode 100644
index 0000000..dc087be
--- /dev/null
+++ b/src/internal/use-mobile/_tests__/use-mobile.test.tsx
@@ -0,0 +1,57 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { useRef } from 'react';
+import { act, render } from '@testing-library/react';
+
+import { useMobile } from '../index';
+
+function Demo() {
+ const renderCount = useRef(0);
+ const isMobile = useMobile();
+ renderCount.current++;
+ return (
+
+ {String(isMobile)}
+ {renderCount.current}
+
+ );
+}
+
+function resizeWindow(width: number) {
+ act(() => {
+ Object.defineProperty(window, 'innerWidth', { value: width });
+ window.dispatchEvent(new CustomEvent('resize'));
+ });
+}
+
+test('should report mobile width on the initial render', () => {
+ resizeWindow(400);
+ const { getByTestId } = render();
+ expect(getByTestId('mobile').textContent).toBe('true');
+});
+
+test('should report desktop width on the initial render', () => {
+ resizeWindow(1200);
+ const { getByTestId } = render();
+ expect(getByTestId('mobile').textContent).toBe('false');
+});
+
+test('should report the updated value after resize', () => {
+ resizeWindow(400);
+ const { getByTestId } = render();
+ const countBefore = getByTestId('render-count').textContent;
+ resizeWindow(1200);
+ const countAfter = getByTestId('render-count').textContent;
+ expect(getByTestId('mobile').textContent).toBe('false');
+ expect(countBefore).not.toEqual(countAfter);
+});
+
+test('no renders when resize does not hit the breakpoint', () => {
+ resizeWindow(1000);
+ const { getByTestId } = render();
+ const countBefore = getByTestId('render-count').textContent;
+ resizeWindow(1200);
+ const countAfter = getByTestId('render-count').textContent;
+ expect(countBefore).toEqual(countAfter);
+});
diff --git a/src/internal/use-mobile/index.ts b/src/internal/use-mobile/index.ts
new file mode 100644
index 0000000..92ba85a
--- /dev/null
+++ b/src/internal/use-mobile/index.ts
@@ -0,0 +1,42 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { createSingletonState } from '../singleton-handler';
+
+import { mobileBreakpoint } from '../breakpoints';
+import { safeMatchMedia } from '../utils/safe-match-media';
+
+export const forceMobileModeSymbol = Symbol.for('awsui-force-mobile-mode');
+
+function getIsMobile() {
+ // allow overriding the mobile mode in tests
+ // any is needed because of this https://github.com/microsoft/TypeScript/issues/36813
+ const forceMobileMode = (globalThis as any)[forceMobileModeSymbol];
+ if (typeof forceMobileMode !== 'undefined') {
+ return forceMobileMode;
+ }
+ if (typeof window === 'undefined') {
+ // assume desktop in server-rendering
+ return false;
+ }
+
+ /**
+ * Some browsers include the scrollbar width in their media query calculations, but
+ * some browsers don't. Thus we can't use `window.innerWidth` or
+ * `document.documentElement.clientWidth` to get a very accurate result (since we
+ * wouldn't know which one of them to use).
+ * Instead, we use the media query here in JS too.
+ */
+ return safeMatchMedia(document.body, `(max-width: ${mobileBreakpoint}px)`);
+}
+
+export const useMobile = createSingletonState({
+ initialState: () => getIsMobile(),
+ factory: handler => {
+ const listener = () => handler(getIsMobile());
+ window.addEventListener('resize', listener);
+ return () => {
+ window.removeEventListener('resize', listener);
+ };
+ },
+});
diff --git a/src/internal/utils/safe-match-media.ts b/src/internal/utils/safe-match-media.ts
new file mode 100644
index 0000000..0f41701
--- /dev/null
+++ b/src/internal/utils/safe-match-media.ts
@@ -0,0 +1,12 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export function safeMatchMedia(element: HTMLElement, query: string) {
+ try {
+ const targetWindow = element.ownerDocument?.defaultView ?? window;
+ return targetWindow.matchMedia?.(query).matches ?? false;
+ } catch (error) {
+ console.warn(error);
+ return false;
+ }
+}
diff --git a/src/internal/visual-mode/index.ts b/src/internal/visual-mode/index.ts
index 173ec0e..5b37f22 100644
--- a/src/internal/visual-mode/index.ts
+++ b/src/internal/visual-mode/index.ts
@@ -8,16 +8,7 @@ import { useStableCallback } from '../stable-callback';
import { isDevelopment } from '../is-development';
import { warnOnce } from '../logging';
import { awsuiVisualRefreshFlag, getGlobal } from '../global-flags';
-
-function safeMatchMedia(element: HTMLElement, query: string) {
- try {
- const targetWindow = element.ownerDocument?.defaultView ?? window;
- return targetWindow.matchMedia?.(query).matches ?? false;
- } catch (error) {
- console.warn(error);
- return false;
- }
-}
+import { safeMatchMedia } from '../utils/safe-match-media';
export function isMotionDisabled(element: HTMLElement): boolean {
return (