Hooks
useFullscreen
A hook that puts a single element into and out of the browser's native fullscreen mode, keeping a `fullscreen` boolean in sync — including when the user presses Esc.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-fullscreen.jsonPrompt
Build a React + TypeScript "useFullscreen" hook (no dependencies beyond React;
uses the browser's Fullscreen API — `Element.requestFullscreen`,
`document.exitFullscreen`, `document.fullscreenElement`, and the
`"fullscreenchange"` event).
Contract
- `useFullscreen<T extends HTMLElement = HTMLElement>(): { ref: (node: T |
null) => void; fullscreen: boolean; enter: () => Promise<boolean>; exit:
() => Promise<boolean>; toggle: () => Promise<boolean>; supported: boolean }`.
- No options object — the generic type parameter just picks the element type
the callback ref accepts.
Behavior
- `ref` is a callback ref (not `useRef` + a mount `useEffect`) that stores the
current node in a ref. It doesn't build a per-node observer or listener —
fullscreen state is a single document-level thing, not per-element.
- `fullscreen` is driven by `useSyncExternalStore`: `subscribe` registers a
`document` `"fullscreenchange"` listener (guarded for `typeof document ===
"undefined"`), `getSnapshot` compares `document.fullscreenElement` against
the ref's current node, `getServerSnapshot` returns `false`. Zero manual
`useEffect` + `setState`.
- `enter()` calls `node.requestFullscreen()` on the tracked node, wrapped so
it never throws: resolves `false` if there's no node attached, the browser
doesn't support the API, or the returned promise rejects (most commonly:
`enter()` wasn't invoked synchronously inside a real user gesture, or the
browser/embedder denied the request).
- `exit()` calls `document.exitFullscreen()`; resolves `false` if nothing is
currently fullscreen or the browser is unsupported, otherwise resolves the
outcome the same non-throwing way.
- `toggle()` calls `exit()` if `fullscreen` is currently `true`, otherwise
`enter()`.
- Esc: the browser exits fullscreen on its own and fires `"fullscreenchange"`
itself — the existing subscription picks that up automatically, so there
is no separate Esc keydown handler to write.
- `supported` reads `document.fullscreenEnabled`; `false` during SSR and on
unsupported browsers.
- No vendor-prefixed (`webkit*`) fallback — targets the unprefixed,
standardized Fullscreen API baseline (every evergreen browser, including
Safari 16.4+).
Rendering & styling
- The hook renders nothing itself. Consumers own the fullscreened element's
markup entirely; once it's actually fullscreen, the browser's UA
stylesheet stretches that element to fill the viewport — no `fixed
inset-0` classes needed from the consumer to achieve that.
- Any "entering/exiting" affordance (an icon/label swap on a button) is the
consumer's job, using semantic tokens (`bg-primary`, `text-muted-foreground`)
and respecting `prefers-reduced-motion` for any transition.
Customization levers
- `:fullscreen` CSS pseudo-class — style the tracked element differently
while it's the active fullscreen element (e.g. `[&:fullscreen]:bg-background`)
without needing any extra hook state.
- Double-click to toggle — wire `onDoubleClick={() => toggle()}` on the
element (or a header inside it) alongside an explicit button; a common
pattern for video players.
- Vendor-prefixed fallback (`webkitRequestFullscreen`/`webkitExitFullscreen`/
`webkitFullscreenElement`) — only layer this in if you must support
pre-16.4 Safari or certain embedded WebViews; the default hook
intentionally omits it to stay standards-only.
- Fullscreen options — pass `{ navigationUI: "auto" | "hide" | "show" }` into
a wrapped `enter()` if you need to control browser UI visibility during
fullscreen (rarely needed).Concepts
- Callback ref, not an effect-tracked ref — the ref callback just stores the current node; there's nothing to (re)connect per node, since the thing being subscribed to (
fullscreenchange) lives ondocument, not on the element. - Document-level
fullscreenchangeas the single source of truth — Esc-triggered exits, exits triggered by other code, and this hook's ownenter()/exit()calls all reconcile through the exact same event, so Esc never needs a separate handler. - User-gesture requirement —
requestFullscreen()must be invoked synchronously inside a real user interaction (a click/keydown handler); calling it after anawaitor inside asetTimeoutgets it silently rejected by the browser.enter()surfaces that as a non-throwingfalse, never an unhandled rejection. - Non-throwing Promise contract —
enter/exit/togglealways resolve to a boolean, so callers branch on the result directly instead of wrapping every call intry/catch. - Fullscreen ≠ CSS "looks fullscreen" — this hook drives the real browser Fullscreen API (browser chrome hides,
<video>/<canvas>can hardware-scale); afixed inset-0layout is a different, purely visual technique with none of that native behavior.