A button that shows one element — a chart, a data table, a map, a video player, a preview pane — full-screen on its own, and keeps telling the truth about it afterwards. Reach for it wherever a panel is too small for the thing inside it: a dashboard chart somebody wants to read properly, a wide data table or log viewer, an embedded map, a code or markdown preview, an image or PDF viewer, a diagram or canvas, a kiosk or presentation view, a video or camera feed. Common asks it answers: "fullscreen button react", "react fullscreen component", "shadcn fullscreen", "requestFullscreen react", "expand chart to fullscreen", "fullscreen a div react", "maximize panel react", "react-full-screen alternative", "screenfull.js alternative", "use-fullscreen hook", "exit fullscreen button stuck", "fullscreen state wrong after escape", "escape key breaks my fullscreen toggle", "document.fullscreenElement react", "fullscreenchange listener react", "requestFullscreen not working", "requestFullscreen NotAllowedError", "fullscreen permissions policy iframe", "requestFullscreen ios safari not working", "element fullscreen iphone", "fullscreen api safari prefix", "webkitRequestFullscreen", "全画面 ボタン react", "フルスクリーン 切り替え react", "Escape で全画面が解除されると表示がずれる". Official shadcn/ui has nothing of the kind, and the measurement is not close. Fetching all sixty-three registry entries today (sixty-two are fetchable; questionnaire is listed and 404s on both style tracks) and grepping 255,796 bytes of source: requestFullscreen, exitFullscreen, fullscreenElement, fullscreenchange, fullscreenEnabled, webkitRequestFullscreen, allowFullScreen and the bare word fullscreen are every one of them zero hits. The four matches for "expand" are sidebar's expanded/collapsed state and a has-aria-expanded rule on a table row. So an agent asked for this writes it from scratch, and the version it writes has a specific bug in it. The bug is a `const [isFullscreen, setIsFullscreen] = useState(false)` flipped inside the click handler. It demos perfectly. Then the user presses Escape — which is how most people leave full screen, along with F11 and the browser's own chrome — and none of those go through the button. The boolean stays true, the label reads "Exit fullscreen" over a windowed page, and clicking it now asks to *leave* a full screen nobody is in, so the button is stuck in the wrong state for good and no amount of pressing fixes it. The only real state is `document.fullscreenElement`, and the only way to stay level with it is to subscribe to `fullscreenchange` and re-read. That is what this does: entering never writes the state, it only asks — the event is the sole writer — so the label cannot drift from the browser, whatever route the user took out. The comparison is the half that is usually missed even by implementations that do subscribe. `!!document.fullscreenElement` is a property of the page, not of your element: put a fullscreen button on both a chart and a table, expand the chart, and the table's button lights up as well and offers to exit something it does not own. The state here is `document.fullscreenElement === target`, and `exit()` refuses to act unless that holds, because `exitFullscreen` is document-wide and would otherwise cancel a full screen that belongs to another component. Two things about the request itself. It is called with nothing awaited ahead of it, because the browser only grants it while the click is still the active user gesture — measure the element or fetch something first and it rejects with NotAllowedError, in production, on a button that worked in development where the await was fast. And the returned promise really does reject: an iframe embedded without `allow="fullscreen"` has the method and is refused by permissions policy, which arrives asynchronously, after the gesture is spent. That leaves the environment the API is simply not in. On an iPhone, Safari has element full-screen for `<video>` and nothing else — `requestFullscreen` is absent, not refused — so a button that assumes the method exists is dead on the most common phone on the web, silently, with nothing in the console. Rather than detect and disappear, this keeps working by other means: where the API is missing, or refused, it covers the viewport with CSS instead and reports `mode: "css"` so you can tell the difference. That fallback is written as inline styles rather than class names on purpose — a panel wearing `h-64 max-w-md rounded-lg` ignores `position: fixed; inset: 0` and stays a small rounded card in the corner, because an explicit height wins over an over-constrained box — and every property's previous inline value is saved and put back on exit. It also paints a backdrop when the target's own background is transparent, taken from the nearest ancestor that paints one so it stays right in both themes, and it wires Escape by hand, since the real API's Escape is not there to inherit. Unmounting restores the target: the alternative is a panel pinned over the page with no way back. Feature detection never reaches the render, so the server and the first client render agree and nothing hydrates twice; `isFullscreenSupported` and the hook's `isSupported` are there for when you want to hide your own control, and both read the permissions policy rather than just the method. The state is carried by `aria-pressed` — an icon swap is not something a screen reader reports — the icon is `aria-hidden`, `iconOnly` keeps the label as the accessible name instead of dropping it, focus-visible rings are the shadcn ones, and it is a real `<button type="button">` so it will not submit the form it sits in. Older Safari's `webkit` spellings are handled for the request, the exit and the event. The API: `targetRef` is the element to expand, plus `enterLabel`, `exitLabel`, `iconOnly`, `cssFallback` and `onFullscreenChange` — which fires on every flip including the ones the user made with Escape, and is not called `onChange` because that is a native attribute of `<button>`. `useFullscreen(targetRef)` returns `{ isFullscreen, mode, isSupported, enter, exit, toggle }` for a control you lay out yourself. One thing worth knowing before you place it: put the button **inside** the element it expands. Everything outside the full-screen element is not rendered, so a button sitting beside the chart vanishes the moment it is pressed, taking the keyboard focus with it and leaving Escape as the only way back. Within pulld it sits next to the components that make a panel readable rather than bigger: image-comparison and image-crop work on one image, scroll-shadow and virtual-list handle a pane that is too small to show everything, and print-button is the other way of getting content out of a cramped box. Distinct from a dialog: this is the browser's own full screen over the whole display, with no overlay, no focus trap and no scrim. One file, one dependency (lucide-react for the two icons), and every colour is a shadcn token, so light and dark follow on their own.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/fullscreen-button.json"