Skip to main content

Search

Search problems, hooks, and pages

Back to About

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

Fullscreen can only be entered from a genuine user gesture. Calling `requestFullscreen` from a timeout or an effect is rejected by every browser.

Where it lives in the repo

  • src/components/layout/fullscreen-button.tsx
  • src/components/layout/app-header.tsx