Skip to content

[fullscreen] Create the Fullscreen component#4755

Open
arturbien wants to merge 19 commits into
mui:masterfrom
arturbien:feat-fullscreen-component
Open

[fullscreen] Create the Fullscreen component#4755
arturbien wants to merge 19 commits into
mui:masterfrom
arturbien:feat-fullscreen-component

Conversation

@arturbien
Copy link
Copy Markdown
Contributor

@arturbien arturbien commented May 6, 2026

Adds a new <Fullscreen /> composite for presenting elements with the browser Fullscreen API, plus the cross-cutting fixes that make it actually compose with the rest of Base UI.

Why

Building fullscreen UX with the raw Fullscreen API on top of Base UI primitives is doable but full of subtle footguns:

  • Element.requestFullscreen() is gated by transient user activation. Wiring this through React state without losing the activation window — especially for controlled mode and detached/imperative triggers — is tricky to get right.
  • When you fullscreen a component that contains a Dialog, Popover, Menu, or any other dismissible UI, opening that popup while in fullscreen and pressing Esc exits fullscreen instead of dismissing the popup. Most apps just ship this broken because there is no documented way to make Esc go to the popup first.
  • Portaled overlays (<Dialog.Portal>, <Popover.Portal>, <Menu.Portal>, etc.) default to document.body, which the browser hides while another element is fullscreen. The portal mounts but never paints — the user sees a frozen UI. To combat that, developers have to create a container context and then pass it to every Tooltip, Dropdown, Dialog, Popover that is nested within a component that is fullscreen-able.
  • There is no obvious way to share fullscreen state across the tree so unrelated components can react (corner controls, "exit" affordances, hotkey listeners).

Today consumers either re-implement this integration in every app (and usually miss one of the above), or pull in a third-party fullscreen wrapper that doesn't compose with Base UI primitives. This PR fixes both ergonomics and the cross-cutting issues.

What's new

import { Fullscreen } from '@base-ui/react/fullscreen';

<Fullscreen.Root>
  <Fullscreen.Trigger>Enter fullscreen</Fullscreen.Trigger>
  <Fullscreen.Container>
    <Fullscreen.Close>Exit</Fullscreen.Close>
  </Fullscreen.Container>
</Fullscreen.Root>;
Part Purpose
Fullscreen.Root Owns state and reconciles with the browser. Supports open / defaultOpen / onOpenChange, disabled, navigationUI, and a target prop for fullscreening any DOM element.
Fullscreen.Container The element passed to requestFullscreen(). Forwards data-fullscreen / data-not-fullscreen / data-supported.
Fullscreen.Trigger Toggles fullscreen state. Auto-exposes aria-pressed / aria-controls and data-disabled when the API is unavailable. Supports detached usage via handle, just like Dialog.Trigger.
Fullscreen.Close Always-exit affordance, typically rendered inside the Container so it stays visible while fullscreen.
Fullscreen.Portal Mounts the Container only while in fullscreen (mirrors Dialog.Portal, supports keepMounted and container).
Fullscreen.Handle / Fullscreen.createHandle() Imperative + detached API. handle.open(), handle.close(), handle.isOpen. Same shape as Dialog.createHandle().
Fullscreen.request() / Fullscreen.exit() Imperative utilities for fire-and-forget cases (e.g. fullscreening document.documentElement).

Why composition with the rest of Base UI is hard — and what we did

Two non-obvious cross-cutting fixes ship with this PR.

1. Esc inside popups dismisses the popup, not fullscreen

Concrete scenario: you fullscreen a component that contains a <Popover> (or <Dialog> / <Menu> / etc.); inside fullscreen, the user opens the popover; they press Esc to dismiss. Without this fix the browser exits fullscreen first, and the popover stays "logically open" but the user is jarringly thrown back to the page. The expected behavior is that Esc dismisses the popover and a second Esc exits fullscreen.

A new global Esc bridge (packages/react/src/fullscreen/escapeBridge.ts) uses the Keyboard Lock API to keep Esc out of the browser's fullscreen handling, lets it reach JS handlers (useDismiss calls event.preventDefault() synchronously on the keydown), and only exits fullscreen if defaultPrevented is still false at the next microtask.

  • All useDismiss-based primitives — Dialog, AlertDialog, Popover, Menu, ContextMenu, Menubar, NavigationMenu, Select, Combobox, Autocomplete, Drawer, Tooltip, PreviewCard — work with this out of the box.
  • The bridge is installed lazily (once per session) from a layout effect in useFullscreenRoot and from Fullscreen.request(), so it survives sideEffects: false tree-shaking.
  • Falls back to the native Esc behavior on Safari/Firefox where Keyboard Lock isn't implemented.

2. Portals re-route automatically into the fullscreen element

useFloatingPortalNode (the shared portal primitive that backs every Base UI portal) now subscribes to fullscreenchange and reroutes the default container into document.fullscreenElement only when the resolved fallback (parentPortalNode ?? document.body) is outside the fullscreen subtree. An explicit container prop is always respected.

Result: dropping a <Dialog> or <Popover> inside a <Fullscreen.Container> Just Works — no manual container plumbing needed. Page-level fullscreen (document.documentElement) is detected and skipped to avoid moving portals that would render correctly anyway. The change is fully backwards-compatible: in the no-fullscreen case, the fallback chain is identical to before.

3. Smaller niceties

  • data-fullscreen / data-not-fullscreen / data-supported / data-disabled on Root, Container, Trigger, Close so consumers can drive CSS without state plumbing.
  • Native :fullscreen and ::backdrop styling continues to work.
  • Lack-of-API support is detected and exposed via a supported state on every part so consumers can render fallback UI on iOS Safari for iPhone.
  • Controlled mode preserves transient activation: flipping open from any user-gesture handler (including a setTimeout within the activation window) drives requestFullscreen() from a layout effect in the same JS task. If the browser rejects, Base UI rolls the controlled state back via onOpenChange(false, { reason: 'none' }) and warns in development.

Anatomy

Default:

<Fullscreen.Root>
  <Fullscreen.Trigger />
  <Fullscreen.Container>
    <Fullscreen.Close />
  </Fullscreen.Container>
</Fullscreen.Root>

Mount only when open:

<Fullscreen.Root>
  <Fullscreen.Trigger />
  <Fullscreen.Portal>
    <Fullscreen.Container>
      <Fullscreen.Close />
    </Fullscreen.Container>
  </Fullscreen.Portal>
</Fullscreen.Root>

Fullscreen any element on the page (no Container needed):

<Fullscreen.Root target={() => document.documentElement}>
  <Fullscreen.Trigger>Fullscreen the page</Fullscreen.Trigger>
</Fullscreen.Root>

Testing

  • 108 unit tests under packages/react/src/fullscreen/ covering controlled/uncontrolled mode, detached triggers, the imperative handle and utilities, the portal lifecycle, the external target prop (including refs to ancestor DOM nodes), the Esc bridge, and the portal-rerouting integration.
  • 5 new unit tests on FloatingPortal covering every branch of the new fullscreen-aware container resolution (default reroute, no-op when already inside, respects explicit container, migration on enter/exit, mount-while-already-fullscreen).
  • All existing popup primitive suites pass unchanged across jsdom and Chromium (Dialog, Popover, Menu, Select, Combobox, NavigationMenu, etc.).
  • Manual experiment page at /experiments/fullscreen exercises every feature: controlled state with navigationUI, detached triggers, imperative handle, Fullscreen.Portal with keepMounted, the external target prop, the imperative Fullscreen.request() / Fullscreen.exit() utilities, and a Dialog-inside-Fullscreen scenario for the Esc bridge and portal rerouting.

Docs

New react/components/fullscreen page covering anatomy, controlled/uncontrolled state, detached triggers, imperative handle, mount-only-when-open, unsupported-browser fallbacks, the navigationUI hint, and the "Fullscreen any element" pattern. The Usage guidelines block calls out user-gesture requirements, iframe allowfullscreen requirements, mobile considerations, and documents the new Esc-inside-popups and portal-auto-rerouting behaviors.

Checklist

  • component implementation (Root, Container, Trigger, Close, Portal)
  • handle + imperative API (createHandle, request, exit)
  • external target prop on Root
  • global Esc bridge for popups inside fullscreen
  • auto portal-rerouting for portaled popups
  • tests (jsdom + Chromium)
  • docs page + demos (CSS Modules + Tailwind)
  • experiment page

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 6, 2026

commit: f6cc501

@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 6, 2026

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+8.47KB(+1.83%) 🔺+2.56KB(+1.73%)

Details of bundle changes

Performance

Total duration: 1,160.98 ms ▼-369.68 ms(-24.2%) | Renders: 53 (+0) | Paint: 1,785.31 ms ▼-556.48 ms(-23.8%)

Test Duration Renders
Checkbox mount (500 instances) 75.04 ms 🔺+5.12 ms(+7.3%) 1 (+0)
Tabs mount (200 instances) 207.16 ms ▼-108.07 ms(-34.3%) 4 (+0)
Menu mount (300 instances) 122.67 ms ▼-51.21 ms(-29.5%) 2 (+0)
Popover mount (300 instances) 86.57 ms ▼-36.38 ms(-29.6%) 2 (+0)
Dialog mount (300 instances) 69.83 ms ▼-33.74 ms(-32.6%) 2 (+0)

…and 7 more — details


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 6, 2026

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit f6cc501
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69fb4f229567bf00088b4ad0
😎 Deploy Preview https://deploy-preview-4755--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

arturbien and others added 7 commits May 6, 2026 13:44
Co-authored-by: Cursor <cursoragent@cursor.com>
`useExternalFullscreenTarget` resolved the `target` prop in a layout effect at
mount time. When the consumer passed a `RefObject` pointing at an ancestor
DOM node, that ref was attached only after the descendant layout effects
fired, so the resolution returned `null` and `containerRef.current` stayed
empty. The trigger then dispatched `setOpen(true)` (firing `onOpenChange`)
but the open-effect bailed because no container was wired up — clicks
appeared to do nothing in production builds where StrictMode's double
invoke could not mask the issue.

Move resolution into `useFullscreenRoot`'s open-effect so it runs at request
time, by which point all ancestor refs are guaranteed to be attached. Adds
a regression test that reproduces the bug with a ref hung on the parent
`<section>` and verifies `aria-controls` is also kept in sync.

The experiment page now has obvious visual feedback (`:fullscreen` background
and `onOpenChange` plumbing) so the new behaviour is easy to verify by hand.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the `useEffect`-based pattern in favor of branching inside
`<Fullscreen.Trigger>`'s `render` prop. The supported branch spreads the
trigger's props onto a `<button>`; the unsupported branch renders a plain
non-interactive node WITHOUT spreading props (the original example
attached `onClick`, `aria-pressed`, focus management, and the trigger
ref to a `<span>`, producing button-like behaviour on an inert element).

Also lead with the safer default — the trigger auto-disables when the
API is unavailable, so most apps don't need to render alternate UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
@zannager zannager added the type: new feature Expand the scope of the product to solve a new problem. label May 7, 2026
@arturbien
Copy link
Copy Markdown
Contributor Author

@atomiks mind taking a look at my PR? 👀

@colmtuite
Copy link
Copy Markdown
Contributor

colmtuite commented May 11, 2026

@arturbien Thanks for contribution. Just a heads up that it might be a while before someone has time to review this, and there is a high chance that it won't be merged. I haven't looked closely at it, that is just the case with any unexpected PR adding a new, unplanned primitive. But we'll see. We're all busy with other work, so please be patient, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants