diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
index 765cfc2ef4..6b64898943 100644
--- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
+++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
@@ -1,13 +1,14 @@
import { Dialog } from '@base-ui/react/dialog';
-import { useDisclosure, useFullscreen } from '@mantine/hooks';
+import { useDisclosure } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
-import { isLocalhost, supportsFullscreen } from '../../../externals';
+import { isLocalhost } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig';
+import { useFullscreen } from '../../hooks/useFullscreen';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -32,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
- const { fullscreen, toggle } = useFullscreen();
+ const { fullscreen, isSupported: isFullscreenSupported, toggle: toggleFullscreen } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
@@ -57,8 +58,14 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
- {supportsFullscreen && (
-
+ {isFullscreenSupported && (
+ {
+ onClose();
+ toggleFullscreen();
+ }}
+ >
Toggle Fullscreen
{fullscreen ? : }
diff --git a/apps/client/src/common/hooks/useFullscreen.ts b/apps/client/src/common/hooks/useFullscreen.ts
new file mode 100644
index 0000000000..390502f7b0
--- /dev/null
+++ b/apps/client/src/common/hooks/useFullscreen.ts
@@ -0,0 +1,60 @@
+import { useCallback, useEffect, useState } from 'react';
+
+interface UseFullscreenReturn {
+ /** Whether the document is currently in fullscreen mode */
+ fullscreen: boolean;
+ /**
+ * Whether the Fullscreen API is available and permitted in the current browsing context.
+ * When false, `toggle` is a no-op and the UI should hide or disable the action.
+ */
+ isSupported: boolean;
+ /**
+ * Toggles fullscreen on/off.
+ * Safe to use directly as an onClick handler — the underlying Promise is handled
+ * internally, so rejections (eg. browser policy, user denial) never surface as
+ * unhandled promise rejections.
+ */
+ toggle: () => void;
+}
+
+/**
+ * Manages browser fullscreen state.
+ *
+ * Compared to @mantine/hooks useFullscreen:
+ * - `isSupported` is co-located so there is a single source of truth
+ * - `toggle` is a plain void callback, safe to pass directly to event handlers
+ * - Error handling is internal; callers do not need try/catch
+ * - Only targets document.documentElement (the ref API is not used in this project)
+ * - No vendor-prefix shims (all supported browsers implement the standard API)
+ */
+export function useFullscreen(): UseFullscreenReturn {
+ const isSupported = Boolean(document.fullscreenEnabled);
+ const [fullscreen, setFullscreen] = useState(Boolean(document.fullscreenElement));
+
+ // Keep `fullscreen` state in sync with the actual browser state.
+ // The fullscreenchange event fires whenever the document enters or exits fullscreen
+ // (including when the user presses Escape to exit), so React state never drifts
+ // from reality. The listener is removed on unmount via the cleanup return.
+ useEffect(() => {
+ const handleChange = () => setFullscreen(Boolean(document.fullscreenElement));
+ document.addEventListener('fullscreenchange', handleChange);
+ return () => document.removeEventListener('fullscreenchange', handleChange);
+ }, []);
+
+ const toggle = useCallback(() => {
+ if (!isSupported) return;
+
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen().catch(() => {
+ // requestFullscreen() can be rejected in certain browser contexts
+ // (eg. browser security policy, extensions blocking fullscreen)
+ });
+ } else {
+ document.exitFullscreen().catch(() => {
+ // exitFullscreen() can also be rejected in rare edge cases
+ });
+ }
+ }, [isSupported]);
+
+ return { fullscreen, isSupported, toggle };
+}
diff --git a/apps/client/src/externals.ts b/apps/client/src/externals.ts
index 0393d65360..b9c38edfa1 100644
--- a/apps/client/src/externals.ts
+++ b/apps/client/src/externals.ts
@@ -27,7 +27,6 @@ export const isLocalhost = currentHostName === 'localhost' || currentHostName ==
export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud');
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
-export const supportsFullscreen = document.fullscreenEnabled;
// resolve entrypoint URLs