Distraction-Free Fullscreen
built with: Fullscreen API
Small on purpose — but it still has the one bug nearly every fullscreen toggle ships with.
The desync everyone hits
The tempting implementation is a boolean flipped on click. It works until the user presses Escape — the browser exits fullscreen without telling your component, and now the button shows "exit fullscreen" while the app is windowed. The next click then tries to exit something already exited.
The fix is to never treat local state as the source of truth. `document.fullscreenElement` is the truth, and a `fullscreenchange` listener syncs to it. Escape, F11, and the button all funnel through the same event, so the icon and label cannot drift.
// src/components/layout/fullscreen-button.tsx
const handleToggle = useCallback(() => {
if (!document.fullscreenElement) {
void document.documentElement.requestFullscreen();
} else if (document.exitFullscreen) {
void document.exitFullscreen();
}
}, []);
useEffect(() => {
const onFullscreenChange = () => setOpen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
}, []);Details worth the extra lines
`requestFullscreen` is called on `document.documentElement` rather than a wrapper div, so the entire app goes fullscreen instead of one subtree — which avoids fixed-position chrome being clipped out of the fullscreen element.
It returns a promise that rejects when the browser denies the request (no user gesture, or an iframe without the right permissions). `void` marks the rejection as deliberately unhandled: there is nothing useful to tell the user, and the state stays correct because the listener never fires.
Both `aria-label` and `title` flip with the state, so screen-reader users and hover-tooltip users get the same information.
Important
Gotcha
Where it lives in the repo
- src/components/layout/fullscreen-button.tsx
- src/components/layout/app-header.tsx