+
Fullscreen experiment
+
+ Use this to validate the Fullscreen API integration in real browsers (Chromium, WebKit,
+ Firefox), including the user-gesture requirement, controlled mode, and the unsupported
+ fallback. The Esc key (or browser exit affordance) should leave the controlled state in
+ sync.
+
+
+
+ Controlled state
+
+
+ Settings
+
+
+ navigationUI:
+ {NAVIGATION_UI_OPTIONS.map((value) => (
+
+ setNavigationUI(value)}
+ />
+ {value}
+
+ ))}
+
+
+
+ setOpen(event.target.checked)}
+ />
+
+ open (controlled)
+
+
+
+
+ setOpen(true)}>
+ Open from external button
+
+ {
+ setTimeout(() => setOpen(true), 500);
+ }}
+ >
+ Open after 500ms (within activation)
+
+ {
+ setTimeout(() => setOpen(true), 6000);
+ }}
+ >
+ Open after 6s (activation expired)
+
+
+
+
+ Last event:{' '}
+ {lastEvent ? `${lastEvent.open ? 'opened' : 'closed'} (${lastEvent.reason})` : '—'}
+
+
+
+ {
+ setOpen(nextOpen);
+ setLastEvent({ open: nextOpen, reason: details.reason });
+ }}
+ navigationUI={navigationUI}
+ >
+
+
+
+ Enter fullscreen
+
+
+
+ Exit
+
+
+
+ Open dialog inside fullscreen
+
+
+
+
+ Dialog inside fullscreen
+ Press Escape to close this dialog. Fullscreen should remain active.
+
+ With fullscreen off , opening this dialog renders its popup in{' '}
+ document.body. Click{' '}
+ Enter fullscreen below — the popup should reroute into the
+ fullscreen container automatically.
+
+
+
+
+ Enter fullscreen
+
+
+
+ Exit fullscreen
+
+ Close dialog
+
+
+
+
+
+
+
+
+
+ Detached + imperative
+
+ Triggers and the imperative handle below are wired to a separate{' '}
+ Fullscreen.Root via{' '}
+ Fullscreen.createHandle(). Detached triggers can live
+ anywhere in the tree; only the trigger that activated the fullscreen receives{' '}
+ data-fullscreen.
+
+
+
+
+ Detached trigger A
+
+
+ Detached trigger B
+
+ detachedHandle.open()}>
+ handle.open()
+
+ detachedHandle.open('detached-a')}
+ >
+ handle.open('detached-a')
+
+ detachedHandle.close()}>
+ handle.close()
+
+
+
+
+
+ Detached fullscreen container
+
+
+ Exit
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ExternalTargetSection() {
+ const [lastEvent, setLastEvent] = React.useState<{
+ open: boolean;
+ reason: Fullscreen.Root.ChangeEventReason;
+ } | null>(null);
+ const sectionRef = React.useRef(null);
+
+ return (
+
+ Fullscreen any element
+
+ The target prop on{' '}
+ <Fullscreen.Root> presents an external element
+ instead of <Fullscreen.Container>. The imperative{' '}
+ Fullscreen.request() and{' '}
+ Fullscreen.exit() utilities cover fire-and-forget
+ cases.
+
+
+
+
+ setLastEvent({ open: nextOpen, reason: details.reason })
+ }
+ >
+
+
+ target={document.documentElement}
+
+
+
+ Close
+
+
+
+
+ setLastEvent({ open: nextOpen, reason: details.reason })
+ }
+ >
+
+
+ target={sectionRef}
+
+
+
+ Close
+
+
+
+ {
+ Fullscreen.request(document.documentElement).catch(() => undefined);
+ }}
+ >
+ Fullscreen.request(html)
+
+ Fullscreen.exit().catch(() => undefined)}
+ >
+ Fullscreen.exit()
+
+
+
+
+ Last managed event:{' '}
+ {lastEvent ? `${lastEvent.open ? 'opened' : 'closed'} (${lastEvent.reason})` : '—'}
+
+
+ );
+}
+
+function getDocumentElement() {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+ return document.documentElement;
+}
+
+function PortalSection() {
+ const [keepMounted, setKeepMounted] = React.useState(false);
+
+ return (
+
+ );
+}
+
+function ExpandIcon(props: React.ComponentProps<'svg'>) {
+ return (
+
+
+
+ );
+}
+
+function CloseIcon(props: React.ComponentProps<'svg'>) {
+ return (
+
+
+
+ );
+}
diff --git a/docs/src/error-codes.json b/docs/src/error-codes.json
index f879c383ecf..ef0db0ed321 100644
--- a/docs/src/error-codes.json
+++ b/docs/src/error-codes.json
@@ -95,5 +95,9 @@
"95": "Base UI: SharedCalendarDayGridBodyContext is missing. must be placed within and must be placed within .",
"96": "Base UI: SharedCalendarDayGridCellContext is missing. must be placed within and must be placed within .",
"97": "Base UI: SharedCalendarRootContext is missing. Calendar parts must be placed within and Range Calendar parts must be placed within .",
- "98": "Base UI: OTPFieldRootContext is missing. OTPField parts must be placed within ."
+ "98": "Base UI: OTPFieldRootContext is missing. OTPField parts must be placed within .",
+ "99": "Base UI: FullscreenRootContext is missing. Fullscreen parts must be placed within , or provided with a handle.",
+ "100": "Base UI: must be used within or provided with a handle.",
+ "101": "Base UI: Fullscreen.request() was called on an element whose owner document does not support the Fullscreen API. Check `document.fullscreenEnabled` before calling. See https://base-ui.com/react/components/fullscreen",
+ "102": "Base UI: cannot be used inside a that has a `target` prop. Choose one or the other: render for an inline fullscreen element, or pass `target` to fullscreen an external element. See https://base-ui.com/react/components/fullscreen"
}
diff --git a/packages/react/package.json b/packages/react/package.json
index e23815a0b5f..fc8240b4681 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -44,6 +44,7 @@
"./field": "./src/field/index.ts",
"./fieldset": "./src/fieldset/index.ts",
"./form": "./src/form/index.ts",
+ "./fullscreen": "./src/fullscreen/index.ts",
"./input": "./src/input/index.ts",
"./menu": "./src/menu/index.ts",
"./menubar": "./src/menubar/index.ts",
diff --git a/packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx b/packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx
index 455feb1027f..6018864500d 100644
--- a/packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx
+++ b/packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx
@@ -1,6 +1,6 @@
-import { expect } from 'vitest';
+import { afterEach, beforeEach, expect } from 'vitest';
import * as React from 'react';
-import { fireEvent, flushMicrotasks, render, screen } from '@mui/internal-test-utils';
+import { act, fireEvent, flushMicrotasks, render, screen } from '@mui/internal-test-utils';
import { isJSDOM } from '@base-ui/utils/detectBrowser';
import { FloatingPortal, useFloating } from '../index';
import { FloatingPortalLite } from '../../utils/FloatingPortalLite';
@@ -150,4 +150,144 @@ describe.skipIf(!isJSDOM)('FloatingPortal', () => {
const portal = document.querySelector('[data-testid="lite-portal"]');
expect(portal).not.toBeNull();
});
+
+ describe('fullscreen rerouting', () => {
+ let fullscreenElement: Element | null = null;
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
+ Document.prototype,
+ 'fullscreenElement',
+ );
+
+ beforeEach(() => {
+ fullscreenElement = null;
+ Object.defineProperty(Document.prototype, 'fullscreenElement', {
+ configurable: true,
+ get: () => fullscreenElement,
+ });
+ });
+
+ afterEach(() => {
+ fullscreenElement = null;
+ if (originalDescriptor) {
+ Object.defineProperty(Document.prototype, 'fullscreenElement', originalDescriptor);
+ } else {
+ Reflect.deleteProperty(Document.prototype, 'fullscreenElement');
+ }
+ });
+
+ function setFullscreenElement(element: Element | null) {
+ fullscreenElement = element;
+ document.dispatchEvent(new Event('fullscreenchange'));
+ }
+
+ test('reroutes the portal into the fullscreen element when default container is outside it', async () => {
+ const fsContainer = document.createElement('div');
+ fsContainer.id = 'fs-container';
+ document.body.appendChild(fsContainer);
+
+ try {
+ render( );
+ fireEvent.click(screen.getByTestId('reference'));
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(document.body);
+
+ await act(async () => {
+ setFullscreenElement(fsContainer);
+ });
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(fsContainer);
+ } finally {
+ fsContainer.remove();
+ }
+ });
+
+ test('does not reroute when the default container is already inside the fullscreen element', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('reference'));
+ await flushMicrotasks();
+
+ await act(async () => {
+ setFullscreenElement(document.documentElement);
+ });
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(document.body);
+ });
+
+ test('respects an explicit `container` prop even while in fullscreen', async () => {
+ const fsContainer = document.createElement('div');
+ const explicitContainer = document.createElement('div');
+ explicitContainer.id = 'explicit';
+ document.body.appendChild(fsContainer);
+ document.body.appendChild(explicitContainer);
+
+ try {
+ render( );
+ fireEvent.click(screen.getByTestId('reference'));
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(explicitContainer);
+
+ await act(async () => {
+ setFullscreenElement(fsContainer);
+ });
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(explicitContainer);
+ } finally {
+ fsContainer.remove();
+ explicitContainer.remove();
+ }
+ });
+
+ test('moves the portal back to body when fullscreen exits', async () => {
+ const fsContainer = document.createElement('div');
+ document.body.appendChild(fsContainer);
+
+ try {
+ render( );
+ fireEvent.click(screen.getByTestId('reference'));
+ await flushMicrotasks();
+
+ await act(async () => {
+ setFullscreenElement(fsContainer);
+ });
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(fsContainer);
+
+ await act(async () => {
+ setFullscreenElement(null);
+ });
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(document.body);
+ } finally {
+ fsContainer.remove();
+ }
+ });
+
+ test('mounts directly into the fullscreen element when opened while fullscreen is already active', async () => {
+ const fsContainer = document.createElement('div');
+ document.body.appendChild(fsContainer);
+
+ try {
+ render( );
+
+ await act(async () => {
+ setFullscreenElement(fsContainer);
+ });
+ await flushMicrotasks();
+
+ fireEvent.click(screen.getByTestId('reference'));
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(fsContainer);
+ } finally {
+ fsContainer.remove();
+ }
+ });
+ });
});
diff --git a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx
index 813082394f5..98b4f1c5bfd 100644
--- a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx
+++ b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx
@@ -8,6 +8,7 @@ import { useId } from '@base-ui/utils/useId';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { EMPTY_OBJECT } from '@base-ui/utils/empty';
+import { contains } from '../../internals/shadowDom';
import { FocusGuard } from '../../utils/FocusGuard';
import {
enableFocusInside,
@@ -50,6 +51,48 @@ export const usePortalContext = () => React.useContext(PortalContext);
const attr = createAttribute('portal');
+interface PrefixedFullscreenDocument {
+ fullscreenElement?: Element | null | undefined;
+ webkitFullscreenElement?: Element | null | undefined;
+}
+
+function getCurrentFullscreenElement(): Element | null {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+ const doc = document as Document & PrefixedFullscreenDocument;
+ return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
+}
+
+/**
+ * Subscribes to `fullscreenchange` events and returns the current
+ * `document.fullscreenElement` (with a webkit fallback).
+ *
+ * Used by `useFloatingPortalNode` to re-route portals into the fullscreen
+ * element while a fullscreen view is active.
+ */
+function useFullscreenElement(): Element | null {
+ const [fullscreenElement, setFullscreenElement] = React.useState(
+ getCurrentFullscreenElement,
+ );
+
+ React.useEffect(() => {
+ if (typeof document === 'undefined') {
+ return undefined;
+ }
+ function update() {
+ setFullscreenElement(getCurrentFullscreenElement());
+ }
+ update();
+ return mergeCleanups(
+ addEventListener(document, 'fullscreenchange', update),
+ addEventListener(document, 'webkitfullscreenchange' as 'fullscreenchange', update),
+ );
+ }, []);
+
+ return fullscreenElement;
+}
+
export interface UseFloatingPortalNodeProps {
ref?: React.Ref | undefined;
container?:
@@ -76,6 +119,13 @@ export function useFloatingPortalNode(
const portalContext = usePortalContext();
const parentPortalNode = portalContext?.portalNode;
+ // Tracks `document.fullscreenElement` so portals can re-route into it when
+ // the default container (`document.body` or a parent portal) would otherwise
+ // be hidden by the browser's fullscreen rendering. Anything outside the
+ // fullscreen subtree is not painted, so a Dialog/Popover/Menu portalled to
+ // body would render but be invisible. See the resolution logic below.
+ const fullscreenElement = useFullscreenElement();
+
const [containerElement, setContainerElement] = React.useState(
null,
);
@@ -107,10 +157,25 @@ export function useFloatingPortalNode(
return;
}
- const resolvedContainer =
- (containerProp && (isNode(containerProp) ? containerProp : containerProp.current)) ??
- parentPortalNode ??
- document.body;
+ const userContainer =
+ containerProp && (isNode(containerProp) ? containerProp : containerProp.current);
+
+ let resolvedContainer: HTMLElement | ShadowRoot | null =
+ userContainer ?? parentPortalNode ?? document.body;
+
+ // If a fullscreen element is active and the default container chain
+ // (parent portal -> body) lands outside of it, the portal would mount
+ // into the DOM but never paint. Reroute to the fullscreen element so
+ // popups, menus, and dialogs remain visible. The user's explicit
+ // `container` prop is always respected.
+ if (
+ !userContainer &&
+ fullscreenElement &&
+ resolvedContainer instanceof Element &&
+ !contains(fullscreenElement, resolvedContainer)
+ ) {
+ resolvedContainer = fullscreenElement as HTMLElement;
+ }
if (resolvedContainer == null) {
if (containerRef.current) {
@@ -126,7 +191,7 @@ export function useFloatingPortalNode(
setPortalNode(null);
setContainerElement(resolvedContainer);
}
- }, [containerProp, parentPortalNode, uniqueId]);
+ }, [containerProp, parentPortalNode, uniqueId, fullscreenElement]);
const portalElement = useRenderElement('div', componentProps, {
ref: [ref, setPortalNodeRef],
diff --git a/packages/react/src/fullscreen/close/FullscreenClose.test.tsx b/packages/react/src/fullscreen/close/FullscreenClose.test.tsx
new file mode 100644
index 00000000000..e143cfb8e04
--- /dev/null
+++ b/packages/react/src/fullscreen/close/FullscreenClose.test.tsx
@@ -0,0 +1,182 @@
+import { expect, vi } from 'vitest';
+import * as React from 'react';
+import { fireEvent, screen, flushMicrotasks } from '@mui/internal-test-utils';
+import { Fullscreen } from '@base-ui/react/fullscreen';
+import { createRenderer, describeConformance } from '#test-utils';
+import { REASONS } from '../../internals/reasons';
+import { installFullscreenApiStubs, type FullscreenApiStubs } from '../root/fullscreenApiTestUtils';
+
+describe(' ', () => {
+ const { render } = createRenderer();
+
+ describeConformance( , () => ({
+ refInstanceof: window.HTMLButtonElement,
+ testComponentPropWith: 'button',
+ button: true,
+ render: (node) => {
+ return render(
+
+ {node}
+ ,
+ );
+ },
+ }));
+
+ describe('behavior', () => {
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ it('exits fullscreen when pressed while open', async () => {
+ const handleOpenChange = vi.fn();
+
+ await render(
+
+ Toggle
+
+ Close
+
+ ,
+ );
+
+ const toggle = screen.getByRole('button', { name: 'Toggle' });
+ fireEvent.click(toggle);
+ await flushMicrotasks();
+
+ expect(toggle).toHaveAttribute('aria-pressed', 'true');
+ handleOpenChange.mockClear();
+
+ const close = screen.getByRole('button', { name: 'Close' });
+ fireEvent.click(close);
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.closePress }),
+ );
+ expect(toggle).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('is a no-op when not currently open', async () => {
+ const handleOpenChange = vi.fn();
+
+ await render(
+
+
+ Close
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+ await flushMicrotasks();
+
+ expect(stubs.exit).not.toHaveBeenCalled();
+ expect(handleOpenChange).not.toHaveBeenCalled();
+ });
+
+ it('reflects the fullscreen state with `data-fullscreen` and `data-not-fullscreen`', async () => {
+ await render(
+
+ Toggle
+
+ Close
+
+ ,
+ );
+
+ const close = screen.getByRole('button', { name: 'Close' });
+ expect(close).toHaveAttribute('data-not-fullscreen');
+ expect(close).not.toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(close).toHaveAttribute('data-fullscreen');
+ expect(close).not.toHaveAttribute('data-not-fullscreen');
+ });
+
+ it('respects `disabled`', async () => {
+ const handleOpenChange = vi.fn();
+
+ await render(
+
+ Toggle
+
+ Close
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+ handleOpenChange.mockClear();
+
+ const close = screen.getByRole('button', { name: 'Close' });
+ expect(close).toHaveAttribute('data-disabled');
+ expect(close).toHaveAttribute('disabled');
+
+ fireEvent.click(close);
+ await flushMicrotasks();
+ expect(handleOpenChange).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('render prop state', () => {
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ it('passes the fullscreen state to render, className, and style callbacks', async () => {
+ const renderSpy = vi.fn();
+ const classNameSpy = vi.fn().mockReturnValue('close-class');
+ const styleSpy = vi.fn().mockReturnValue({ color: 'red' });
+
+ await render(
+
+ Toggle
+
+ {
+ renderSpy(state);
+ return (
+
+ Close
+
+ );
+ }}
+ />
+
+ ,
+ );
+
+ const initialState = renderSpy.mock.calls.at(-1)?.[0];
+ expect(initialState).toEqual({ open: false, disabled: false, supported: true });
+ expect(classNameSpy).toHaveBeenLastCalledWith(initialState);
+ expect(styleSpy).toHaveBeenLastCalledWith(initialState);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ const openState = renderSpy.mock.calls.at(-1)?.[0];
+ expect(openState).toEqual({ open: true, disabled: false, supported: true });
+ expect(classNameSpy).toHaveBeenLastCalledWith(openState);
+ expect(styleSpy).toHaveBeenLastCalledWith(openState);
+ });
+ });
+});
diff --git a/packages/react/src/fullscreen/close/FullscreenClose.tsx b/packages/react/src/fullscreen/close/FullscreenClose.tsx
new file mode 100644
index 00000000000..026e1057f39
--- /dev/null
+++ b/packages/react/src/fullscreen/close/FullscreenClose.tsx
@@ -0,0 +1,74 @@
+'use client';
+import * as React from 'react';
+import { useStableCallback } from '@base-ui/utils/useStableCallback';
+import { useRenderElement } from '../../internals/useRenderElement';
+import type { BaseUIComponentProps, NativeButtonProps } from '../../internals/types';
+import { useButton } from '../../internals/use-button';
+import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
+import { REASONS } from '../../internals/reasons';
+import { useFullscreenRootContext } from '../root/FullscreenRootContext';
+import type { FullscreenRootState } from '../root/FullscreenRoot';
+import { fullscreenStateMapping } from '../root/stateAttributesMapping';
+
+/**
+ * A button that exits the fullscreen container.
+ * Renders a `` element.
+ *
+ * Documentation: [Base UI Fullscreen](https://base-ui.com/react/components/fullscreen)
+ */
+export const FullscreenClose = React.forwardRef(function FullscreenClose(
+ componentProps: FullscreenClose.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ className,
+ disabled: disabledProp = false,
+ nativeButton = true,
+ render,
+ style,
+ ...elementProps
+ } = componentProps;
+
+ const { store } = useFullscreenRootContext();
+
+ const open = store.useState('open');
+ const supported = store.useState('supported');
+
+ const { getButtonProps, buttonRef } = useButton({
+ disabled: disabledProp,
+ native: nativeButton,
+ });
+
+ const handleClick = useStableCallback((event: React.MouseEvent) => {
+ if (disabledProp || !store.select('open')) {
+ return;
+ }
+ store.setOpen(false, createChangeEventDetails(REASONS.closePress, event.nativeEvent));
+ });
+
+ const state: FullscreenCloseState = React.useMemo(
+ () => ({
+ open,
+ disabled: disabledProp,
+ supported,
+ }),
+ [open, disabledProp, supported],
+ );
+
+ return useRenderElement('button', componentProps, {
+ state,
+ ref: [forwardedRef, buttonRef],
+ props: [{ onClick: handleClick }, elementProps, getButtonProps],
+ stateAttributesMapping: fullscreenStateMapping,
+ });
+});
+
+export interface FullscreenCloseProps
+ extends NativeButtonProps, BaseUIComponentProps<'button', FullscreenCloseState> {}
+
+export interface FullscreenCloseState extends FullscreenRootState {}
+
+export namespace FullscreenClose {
+ export type Props = FullscreenCloseProps;
+ export type State = FullscreenCloseState;
+}
diff --git a/packages/react/src/fullscreen/close/FullscreenCloseDataAttributes.ts b/packages/react/src/fullscreen/close/FullscreenCloseDataAttributes.ts
new file mode 100644
index 00000000000..14fb8011236
--- /dev/null
+++ b/packages/react/src/fullscreen/close/FullscreenCloseDataAttributes.ts
@@ -0,0 +1,10 @@
+export enum FullscreenCloseDataAttributes {
+ /**
+ * Present when the container is currently displayed in fullscreen.
+ */
+ fullscreen = 'data-fullscreen',
+ /**
+ * Present when the container is not currently displayed in fullscreen.
+ */
+ notFullscreen = 'data-not-fullscreen',
+}
diff --git a/packages/react/src/fullscreen/container/FullscreenContainer.test.tsx b/packages/react/src/fullscreen/container/FullscreenContainer.test.tsx
new file mode 100644
index 00000000000..8ee04bfe6f2
--- /dev/null
+++ b/packages/react/src/fullscreen/container/FullscreenContainer.test.tsx
@@ -0,0 +1,98 @@
+import { expect, vi } from 'vitest';
+import * as React from 'react';
+import { fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils';
+import { Fullscreen } from '@base-ui/react/fullscreen';
+import { createRenderer, describeConformance } from '#test-utils';
+import { installFullscreenApiStubs, type FullscreenApiStubs } from '../root/fullscreenApiTestUtils';
+
+describe(' ', () => {
+ const { render } = createRenderer();
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ describeConformance( , () => ({
+ refInstanceof: window.HTMLDivElement,
+ render: (node) => {
+ return render({node} );
+ },
+ }));
+
+ describe('render prop state', () => {
+ it('passes the fullscreen state to render, className, and style callbacks', async () => {
+ const renderSpy = vi.fn();
+ const classNameSpy = vi.fn().mockReturnValue('container-class');
+ const styleSpy = vi.fn().mockReturnValue({ color: 'red' });
+
+ await render(
+
+ Toggle
+ {
+ renderSpy(state);
+ return
;
+ }}
+ />
+ ,
+ );
+
+ const initialState = renderSpy.mock.calls.at(-1)?.[0];
+ expect(initialState).toEqual({ open: false, disabled: false, supported: true });
+ expect(classNameSpy).toHaveBeenLastCalledWith(initialState);
+ expect(styleSpy).toHaveBeenLastCalledWith(initialState);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ const openState = renderSpy.mock.calls.at(-1)?.[0];
+ expect(openState).toEqual({ open: true, disabled: false, supported: true });
+ expect(classNameSpy).toHaveBeenLastCalledWith(openState);
+ expect(styleSpy).toHaveBeenLastCalledWith(openState);
+ });
+ });
+
+ describe('with `target` on Root', () => {
+ it('throws when used inside a that has a `target` prop', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ let caught: unknown = null;
+
+ class Boundary extends React.Component<{ children: React.ReactNode }, { error: unknown }> {
+ state = { error: null };
+
+ static getDerivedStateFromError(error: unknown) {
+ caught = error;
+ return { error };
+ }
+
+ render() {
+ return this.state.error ? null : this.props.children;
+ }
+ }
+
+ const externalTarget = document.createElement('section');
+ document.body.appendChild(externalTarget);
+
+ await render(
+
+ externalTarget}>
+
+
+ ,
+ );
+
+ expect(caught).toBeInstanceOf(Error);
+ expect((caught as Error).message).toMatch(//);
+
+ document.body.removeChild(externalTarget);
+ errorSpy.mockRestore();
+ });
+ });
+});
diff --git a/packages/react/src/fullscreen/container/FullscreenContainer.tsx b/packages/react/src/fullscreen/container/FullscreenContainer.tsx
new file mode 100644
index 00000000000..bcb3e059d66
--- /dev/null
+++ b/packages/react/src/fullscreen/container/FullscreenContainer.tsx
@@ -0,0 +1,134 @@
+'use client';
+import * as React from 'react';
+import { addEventListener } from '@base-ui/utils/addEventListener';
+import { ownerDocument } from '@base-ui/utils/owner';
+import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
+import { useStableCallback } from '@base-ui/utils/useStableCallback';
+import { mergeCleanups } from '@base-ui/utils/mergeCleanups';
+import { BaseUIComponentProps } from '../../internals/types';
+import { useRenderElement } from '../../internals/useRenderElement';
+import { useBaseUiId } from '../../internals/useBaseUiId';
+import { useFullscreenRootContext } from '../root/FullscreenRootContext';
+import type { FullscreenRootState } from '../root/FullscreenRoot';
+import { fullscreenStateMapping } from '../root/stateAttributesMapping';
+import { FullscreenPortalContext } from '../portal/FullscreenPortalContext';
+import {
+ FULLSCREEN_CHANGE_EVENTS,
+ FULLSCREEN_ERROR_EVENTS,
+ isFullscreenEnabled,
+} from '../root/fullscreenApi';
+
+/**
+ * The element that is presented in fullscreen.
+ * Renders a `` element.
+ *
+ * Documentation: [Base UI Fullscreen](https://base-ui.com/react/components/fullscreen)
+ */
+export const FullscreenContainer = React.forwardRef(function FullscreenContainer(
+ componentProps: FullscreenContainer.Props,
+ forwardedRef: React.ForwardedRef
,
+) {
+ const { className, id: idProp, render, style, ...elementProps } = componentProps;
+
+ const { store, handleContainerUnmount, handleFullscreenChange, handleFullscreenError } =
+ useFullscreenRootContext();
+
+ // When the container is mounted inside `` (typically with
+ // `keepMounted`), it should be hidden from the page while not in fullscreen
+ // — matching how `` behaves inside ``. Outside
+ // a portal, this context is `undefined` and the container stays visible.
+ const inPortal = React.useContext(FullscreenPortalContext) !== undefined;
+
+ const open = store.useState('open');
+ const disabled = store.useState('disabled');
+ const supported = store.useState('supported');
+ const hasExternalTarget = store.useState('hasExternalTarget');
+
+ if (hasExternalTarget) {
+ throw new Error(
+ 'Base UI: cannot be used inside a that has a `target` prop. ' +
+ 'Choose one or the other: render for an inline fullscreen element, ' +
+ 'or pass `target` to fullscreen an external element. ' +
+ 'See https://base-ui.com/react/components/fullscreen',
+ );
+ }
+
+ const defaultContainerId = useBaseUiId();
+ const containerId = idProp ?? defaultContainerId;
+
+ useIsoLayoutEffect(() => {
+ store.set('containerId', containerId);
+ return () => {
+ store.set('containerId', undefined);
+ };
+ }, [containerId, store]);
+
+ const setContainer = useStableCallback((node: HTMLDivElement | null) => {
+ store.context.containerRef.current = node;
+ if (node) {
+ store.set('supported', isFullscreenEnabled(ownerDocument(node)));
+ }
+ });
+
+ React.useEffect(() => {
+ const container = store.context.containerRef.current;
+ if (!container) {
+ return undefined;
+ }
+ const doc = ownerDocument(container);
+ const cleanups: Array<() => void> = [];
+ for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
+ cleanups.push(addEventListener(doc, eventName, handleFullscreenChange));
+ }
+ for (const eventName of FULLSCREEN_ERROR_EVENTS) {
+ cleanups.push(addEventListener(doc, eventName, handleFullscreenError));
+ }
+ const cleanup = mergeCleanups(...cleanups);
+ return () => {
+ cleanup();
+ // Only fire the unmount handler if this is a real unmount (the DOM node
+ // is no longer connected). React's strict-mode dev-only effect double-
+ // invocation tears down and re-runs effects without unmounting the DOM
+ // node; we don't want that to be observed as a "container removed"
+ // signal that resets `open` back to false.
+ if (!container.isConnected) {
+ handleContainerUnmount();
+ }
+ };
+ }, [store, handleContainerUnmount, handleFullscreenChange, handleFullscreenError]);
+
+ const state: FullscreenContainerState = React.useMemo(
+ () => ({ open, disabled, supported }),
+ [open, disabled, supported],
+ );
+
+ return useRenderElement('div', componentProps, {
+ state,
+ ref: [forwardedRef, setContainer],
+ props: [
+ {
+ id: containerId,
+ // Hide the container while it sits in a portal but not in fullscreen
+ // so consumer styles (e.g. `width: 100vw`) do not leak into the page.
+ // The attribute is dropped on the same React commit that flips `open`
+ // to true, so it is gone before the layout effect calls
+ // `requestFullscreen()` on this element.
+ hidden: inPortal && !open ? true : undefined,
+ },
+ elementProps,
+ ],
+ stateAttributesMapping: fullscreenStateMapping,
+ });
+});
+
+export interface FullscreenContainerState extends FullscreenRootState {}
+
+export interface FullscreenContainerProps extends BaseUIComponentProps<
+ 'div',
+ FullscreenContainerState
+> {}
+
+export namespace FullscreenContainer {
+ export type State = FullscreenContainerState;
+ export type Props = FullscreenContainerProps;
+}
diff --git a/packages/react/src/fullscreen/container/FullscreenContainerDataAttributes.ts b/packages/react/src/fullscreen/container/FullscreenContainerDataAttributes.ts
new file mode 100644
index 00000000000..10d41a51fdc
--- /dev/null
+++ b/packages/react/src/fullscreen/container/FullscreenContainerDataAttributes.ts
@@ -0,0 +1,10 @@
+export enum FullscreenContainerDataAttributes {
+ /**
+ * Present when the container is currently displayed in fullscreen.
+ */
+ fullscreen = 'data-fullscreen',
+ /**
+ * Present when the container is not currently displayed in fullscreen.
+ */
+ notFullscreen = 'data-not-fullscreen',
+}
diff --git a/packages/react/src/fullscreen/escapeBridge.ts b/packages/react/src/fullscreen/escapeBridge.ts
new file mode 100644
index 00000000000..4d8b9c78113
--- /dev/null
+++ b/packages/react/src/fullscreen/escapeBridge.ts
@@ -0,0 +1,122 @@
+'use client';
+
+import {
+ exitDocumentFullscreen,
+ FULLSCREEN_CHANGE_EVENTS,
+ getFullscreenElement,
+} from './root/fullscreenApi';
+
+// Keyboard Lock API surface (https://wicg.github.io/keyboard-lock/). Only Chromium
+// browsers expose this today; Safari and Firefox return `undefined` and we
+// silently fall back to the browser-native Esc-exits-fullscreen behavior.
+interface KeyboardLockAPI {
+ lock: (keyCodes?: string[]) => Promise;
+ unlock: () => void;
+}
+
+/**
+ * Resolves the Keyboard Lock API on the current `navigator` if it's actually
+ * usable. Returns `undefined` when:
+ *
+ * - we're on the server (`navigator` is `undefined`);
+ * - the browser doesn't ship `navigator.keyboard` (Safari, Firefox);
+ * - the browser exposes `navigator.keyboard` but doesn't implement both
+ * `lock` and `unlock` as functions (defensive check against future
+ * extensions, partial polyfills, or vendor stubs).
+ *
+ * Verifying the shape — not just the presence of `navigator.keyboard` — keeps
+ * the bridge from crashing with a synchronous `TypeError` when one of the
+ * methods is missing, which a `.catch()` on the promise can't recover from.
+ */
+function getKeyboardLock(): KeyboardLockAPI | undefined {
+ if (typeof navigator === 'undefined') {
+ return undefined;
+ }
+ const keyboard = (navigator as Navigator & { keyboard?: unknown }).keyboard as
+ | Partial
+ | undefined;
+ if (
+ keyboard != null &&
+ typeof keyboard.lock === 'function' &&
+ typeof keyboard.unlock === 'function'
+ ) {
+ return keyboard as KeyboardLockAPI;
+ }
+ return undefined;
+}
+
+let installed = false;
+
+function handleFullscreenChange() {
+ const keyboard = getKeyboardLock();
+ if (!keyboard) {
+ return;
+ }
+ if (getFullscreenElement(document)) {
+ // Lock Esc so it reaches JS handlers (Dialog, Popover, Menu, etc.) instead
+ // of the browser using it to exit fullscreen.
+ keyboard.lock(['Escape']).catch(() => {
+ // If the lock is rejected (e.g. browser policy), we fall back to the
+ // native Esc-exits-fullscreen behavior. Nothing to do here.
+ });
+ } else {
+ // Per the spec, leaving fullscreen automatically releases any lock, but
+ // calling unlock() here is idempotent and keeps state predictable across
+ // browsers that don't strictly follow the spec.
+ keyboard.unlock();
+ }
+}
+
+function handleKeyDown(event: KeyboardEvent) {
+ if (event.key !== 'Escape') {
+ return;
+ }
+ if (!getFullscreenElement(document)) {
+ return;
+ }
+ // Defer the exit decision to a microtask so synchronous Esc handlers later
+ // in the bubble (such as `useDismiss` on an open ) get a
+ // chance to call `event.preventDefault()` first. If they did, we leave
+ // fullscreen alone; otherwise we exit, matching the native
+ // Esc-exits-fullscreen contract.
+ queueMicrotask(() => {
+ if (event.defaultPrevented) {
+ return;
+ }
+ if (!getFullscreenElement(document)) {
+ return;
+ }
+ exitDocumentFullscreen(document)?.catch(() => {
+ // The next `fullscreenchange` event reconciles store state if needed.
+ });
+ });
+}
+
+/**
+ * Installs the document-level Esc bridge for fullscreen mode.
+ *
+ * - Captures the Escape key (via the Keyboard Lock API in supporting browsers)
+ * while a document element is in fullscreen, so `Dialog`, `Popover`, `Menu`,
+ * and any other dismissible popup gets a chance to dismiss on Esc before the
+ * browser exits fullscreen.
+ * - Falls back to the browser-native Esc-exits-fullscreen behavior on browsers
+ * that don't implement Keyboard Lock (Safari, Firefox).
+ *
+ * Called from the Fullscreen runtime entry points (`useFullscreenRoot` and
+ * `Fullscreen.request`) so it survives bundler tree-shaking under
+ * `sideEffects: false`. Idempotent and SSR-safe (no-op when `document` is
+ * unavailable).
+ */
+export function installFullscreenEscapeBridge() {
+ if (installed) {
+ return;
+ }
+ if (typeof document === 'undefined') {
+ return;
+ }
+ installed = true;
+ for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
+ document.addEventListener(eventName, handleFullscreenChange);
+ }
+ document.addEventListener('keydown', handleKeyDown);
+}
diff --git a/packages/react/src/fullscreen/imperative.test.ts b/packages/react/src/fullscreen/imperative.test.ts
new file mode 100644
index 00000000000..098957a5074
--- /dev/null
+++ b/packages/react/src/fullscreen/imperative.test.ts
@@ -0,0 +1,95 @@
+import { expect, vi } from 'vitest';
+import { Fullscreen } from '@base-ui/react/fullscreen';
+import { installFullscreenApiStubs, type FullscreenApiStubs } from './root/fullscreenApiTestUtils';
+
+describe('Fullscreen imperative API', () => {
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ describe('Fullscreen.request', () => {
+ it('calls Element.requestFullscreen on the given element', async () => {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+
+ await Fullscreen.request(element);
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(document.fullscreenElement).toBe(element);
+
+ document.body.removeChild(element);
+ });
+
+ it('forwards navigationUI option to requestFullscreen', async () => {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+
+ await Fullscreen.request(element, { navigationUI: 'hide' });
+
+ expect(stubs.request).toHaveBeenCalledWith({ navigationUI: 'hide' });
+
+ document.body.removeChild(element);
+ });
+
+ it('rejects when the Fullscreen API is not available on the element', async () => {
+ // Synthesize an element-like object that exposes neither
+ // `requestFullscreen` nor the prefixed `webkitRequestFullscreen` so the
+ // wrapper's "API unavailable" branch fires. Avoiding mutation of real DOM
+ // prototypes keeps this stable across both jsdom and Chromium.
+ const fakeElement = {} as Element;
+ await expect(Fullscreen.request(fakeElement)).rejects.toThrowError(/Fullscreen API/);
+ });
+
+ it('propagates rejections from the browser', async () => {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+ stubs.request.mockImplementation(() => Promise.reject(new TypeError('blocked')));
+
+ await expect(Fullscreen.request(element)).rejects.toThrow('blocked');
+
+ document.body.removeChild(element);
+ });
+ });
+
+ describe('Fullscreen.exit', () => {
+ it('calls Document.exitFullscreen', async () => {
+ const element = document.createElement('div');
+ document.body.appendChild(element);
+ await Fullscreen.request(element);
+ expect(document.fullscreenElement).toBe(element);
+
+ await Fullscreen.exit();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(document.fullscreenElement).toBeNull();
+
+ document.body.removeChild(element);
+ });
+
+ it('resolves to a no-op when no element is currently in fullscreen', async () => {
+ expect(document.fullscreenElement).toBeNull();
+
+ await Fullscreen.exit();
+
+ expect(stubs.exit).not.toHaveBeenCalled();
+ });
+
+ it('exits fullscreen on a custom document when one is provided', async () => {
+ const exitSpy = vi.fn().mockImplementation(() => Promise.resolve());
+ const fakeDoc = {
+ exitFullscreen: exitSpy,
+ fullscreenElement: document.createElement('div'),
+ } as unknown as Document;
+
+ await Fullscreen.exit(fakeDoc);
+
+ expect(exitSpy).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/packages/react/src/fullscreen/imperative.ts b/packages/react/src/fullscreen/imperative.ts
new file mode 100644
index 00000000000..e714d7abfa2
--- /dev/null
+++ b/packages/react/src/fullscreen/imperative.ts
@@ -0,0 +1,63 @@
+import { installFullscreenEscapeBridge } from './escapeBridge';
+import { exitDocumentFullscreen, requestElementFullscreen } from './root/fullscreenApi';
+import type { FullscreenNavigationUI } from './root/useFullscreenRoot';
+
+export interface FullscreenRequestOptions {
+ /**
+ * Hint to the browser describing how the navigation UI should be presented
+ * while the element is in fullscreen. Forwarded to `Element.requestFullscreen()`.
+ * @default 'auto'
+ */
+ navigationUI?: FullscreenNavigationUI | undefined;
+}
+
+/**
+ * Imperatively requests fullscreen for the given element.
+ *
+ * Must be called from a user-gesture event handler (or from a task that still
+ * inherits a recent activation). Returns a promise that resolves once the
+ * browser enters fullscreen, or rejects if the request was blocked or the
+ * Fullscreen API is unavailable on the element.
+ *
+ * Use this for fire-and-forget cases like fullscreening the entire page.
+ * Prefer `` when you need managed open/close state, triggers,
+ * or `data-fullscreen` attributes.
+ *
+ * @param element The element to present in fullscreen.
+ * @param options Optional options forwarded to the browser API.
+ *
+ * Documentation: [Base UI Fullscreen](https://base-ui.com/react/components/fullscreen)
+ */
+export function request(element: Element, options: FullscreenRequestOptions = {}): Promise {
+ installFullscreenEscapeBridge();
+ const { navigationUI = 'auto' } = options;
+ const promise = requestElementFullscreen(element as HTMLElement, navigationUI);
+ if (promise === null) {
+ return Promise.reject(
+ new Error(
+ 'Base UI: Fullscreen.request() was called on an element whose owner document does not support the Fullscreen API. ' +
+ 'Check `document.fullscreenEnabled` before calling. ' +
+ 'See https://base-ui.com/react/components/fullscreen',
+ ),
+ );
+ }
+ return promise;
+}
+
+/**
+ * Imperatively exits fullscreen on the given document (defaults to the global
+ * document).
+ *
+ * Returns a promise that resolves once the browser has exited fullscreen, or
+ * resolves immediately when no element is currently fullscreen.
+ *
+ * Documentation: [Base UI Fullscreen](https://base-ui.com/react/components/fullscreen)
+ */
+export function exit(doc?: Document): Promise {
+ if (typeof document === 'undefined' && doc === undefined) {
+ return Promise.resolve();
+ }
+ const targetDoc = doc ?? document;
+ const promise = exitDocumentFullscreen(targetDoc);
+ return promise ?? Promise.resolve();
+}
diff --git a/packages/react/src/fullscreen/index.parts.ts b/packages/react/src/fullscreen/index.parts.ts
new file mode 100644
index 00000000000..b06224ca560
--- /dev/null
+++ b/packages/react/src/fullscreen/index.parts.ts
@@ -0,0 +1,10 @@
+export { FullscreenRoot as Root } from './root/FullscreenRoot';
+export { FullscreenContainer as Container } from './container/FullscreenContainer';
+export { FullscreenTrigger as Trigger } from './trigger/FullscreenTrigger';
+export { FullscreenClose as Close } from './close/FullscreenClose';
+export { FullscreenPortal as Portal } from './portal/FullscreenPortal';
+export {
+ createFullscreenHandle as createHandle,
+ FullscreenHandle as Handle,
+} from './store/FullscreenHandle';
+export { request, exit } from './imperative';
diff --git a/packages/react/src/fullscreen/index.ts b/packages/react/src/fullscreen/index.ts
new file mode 100644
index 00000000000..c076505b1d8
--- /dev/null
+++ b/packages/react/src/fullscreen/index.ts
@@ -0,0 +1,8 @@
+export * as Fullscreen from './index.parts';
+
+export type * from './root/FullscreenRoot';
+export type * from './container/FullscreenContainer';
+export type * from './trigger/FullscreenTrigger';
+export type * from './close/FullscreenClose';
+export type * from './portal/FullscreenPortal';
+export type * from './imperative';
diff --git a/packages/react/src/fullscreen/portal/FullscreenPortal.test.tsx b/packages/react/src/fullscreen/portal/FullscreenPortal.test.tsx
new file mode 100644
index 00000000000..0dae201288a
--- /dev/null
+++ b/packages/react/src/fullscreen/portal/FullscreenPortal.test.tsx
@@ -0,0 +1,331 @@
+import { expect } from 'vitest';
+import * as React from 'react';
+import { act, fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils';
+import { Fullscreen } from '@base-ui/react/fullscreen';
+import { createRenderer } from '#test-utils';
+import { installFullscreenApiStubs, type FullscreenApiStubs } from '../root/fullscreenApiTestUtils';
+
+describe(' ', () => {
+ const { render } = createRenderer();
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ describe('mount lifecycle', () => {
+ it('does not mount its children while closed', async () => {
+ await render(
+
+ Toggle
+
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId('container')).toBe(null);
+ });
+
+ it('mounts children and enters fullscreen when the trigger is pressed', async () => {
+ await render(
+
+ Toggle
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ const container = screen.getByTestId('container');
+ expect(container).toHaveAttribute('data-fullscreen');
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(container.parentElement).toBe(document.body);
+ });
+
+ it('unmounts children when closed via the Close button', async () => {
+ await render(
+
+ Toggle
+
+
+ Close
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+ expect(screen.getByTestId('container')).toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(screen.queryByTestId('container')).toBe(null);
+ });
+
+ it('unmounts children when the user exits via the Esc key', async () => {
+ await render(
+
+ Toggle
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+ expect(screen.getByTestId('container')).toHaveAttribute('data-fullscreen');
+
+ await act(async () => {
+ stubs.setActiveElement(null);
+ stubs.dispatchChange();
+ });
+ await flushMicrotasks();
+
+ expect(screen.queryByTestId('container')).toBe(null);
+ });
+
+ it('unmounts children when controlled `open` flips to false', async () => {
+ function App() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ setOpen(true)}>
+ External open
+
+ setOpen(false)}>
+ External close
+
+
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'External open' }));
+ await flushMicrotasks();
+ expect(screen.getByTestId('container')).toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(screen.getByRole('button', { name: 'External close' }));
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(screen.queryByTestId('container')).toBe(null);
+ });
+ });
+
+ describe('keepMounted', () => {
+ it('keeps children mounted while the container is not in fullscreen', async () => {
+ await render(
+
+ Toggle
+
+
+
+ ,
+ );
+
+ const container = screen.getByTestId('container');
+ expect(container).toHaveAttribute('data-not-fullscreen');
+ expect(container.parentElement).toBe(document.body);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(container).toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(container).toHaveAttribute('data-not-fullscreen');
+ expect(screen.getByTestId('container')).toBe(container);
+ });
+
+ it('hides the container while not in fullscreen so it does not leak into the page', async () => {
+ await render(
+
+ Toggle
+
+
+
+ ,
+ );
+
+ const container = screen.getByTestId('container');
+ expect(container).toHaveAttribute('hidden');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(container).not.toHaveAttribute('hidden');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(container).toHaveAttribute('hidden');
+ });
+
+ it('does not set `hidden` on a container rendered without ``', async () => {
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ expect(screen.getByTestId('container')).not.toHaveAttribute('hidden');
+ });
+ });
+
+ describe('container prop', () => {
+ it('accepts an `Element`', async () => {
+ const target = document.createElement('div');
+ target.setAttribute('id', 'portal-target-element');
+ document.body.appendChild(target);
+
+ try {
+ await render(
+
+ Toggle
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('container').parentElement).toBe(target);
+ } finally {
+ target.remove();
+ }
+ });
+
+ it('accepts a `RefObject`', async () => {
+ function App() {
+ const ref = React.useRef(null);
+ return (
+
+
+
+ Toggle
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ const target = screen.getByTestId('portal-target');
+ expect(screen.getByTestId('container').parentElement).toBe(target);
+ });
+
+ it('accepts a function returning the target element', async () => {
+ const target = document.createElement('div');
+ target.setAttribute('id', 'portal-target-fn');
+ document.body.appendChild(target);
+
+ try {
+ await render(
+
+ Toggle
+ target}>
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(screen.getByTestId('container').parentElement).toBe(target);
+ } finally {
+ target.remove();
+ }
+ });
+ });
+
+ describe('handle integration', () => {
+ it('mounts children when a detached trigger sharing a handle is pressed', async () => {
+ const handle = Fullscreen.createHandle();
+
+ function App() {
+ return (
+
+ Toggle
+
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ expect(screen.queryByTestId('container')).toBe(null);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ const container = screen.getByTestId('container');
+ expect(container).toHaveAttribute('data-fullscreen');
+ expect(stubs.request).toHaveBeenCalledOnce();
+ });
+
+ it('mounts children when `handle.open()` is called from a click handler', async () => {
+ const handle = Fullscreen.createHandle();
+
+ function App() {
+ return (
+
+ handle.open()}>
+ Imperative open
+
+
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ expect(screen.queryByTestId('container')).toBe(null);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Imperative open' }));
+ await flushMicrotasks();
+
+ const container = screen.getByTestId('container');
+ expect(container).toHaveAttribute('data-fullscreen');
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(handle.isOpen).toBe(true);
+ });
+ });
+});
diff --git a/packages/react/src/fullscreen/portal/FullscreenPortal.tsx b/packages/react/src/fullscreen/portal/FullscreenPortal.tsx
new file mode 100644
index 00000000000..3e7325b4dd8
--- /dev/null
+++ b/packages/react/src/fullscreen/portal/FullscreenPortal.tsx
@@ -0,0 +1,86 @@
+'use client';
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import { useFullscreenRootContext } from '../root/FullscreenRootContext';
+import { FullscreenPortalContext } from './FullscreenPortalContext';
+
+/**
+ * A portal that mounts its children outside the regular React tree (by default
+ * into ``) only while the container is in fullscreen.
+ *
+ * Useful for "dialog-like" fullscreen UI: the trigger lives in the regular
+ * page, and the fullscreen content is absent from the DOM until the user
+ * enters fullscreen.
+ *
+ * Doesn't render its own HTML element.
+ *
+ * Documentation: [Base UI Fullscreen](https://base-ui.com/react/components/fullscreen)
+ */
+export function FullscreenPortal(props: FullscreenPortal.Props) {
+ const { children, container, keepMounted = false } = props;
+
+ const { store } = useFullscreenRootContext();
+ const open = store.useState('open');
+
+ if (!open && !keepMounted) {
+ return null;
+ }
+ if (typeof document === 'undefined') {
+ return null;
+ }
+
+ const target = resolveContainer(container);
+ if (!target) {
+ return null;
+ }
+
+ return (
+
+ {ReactDOM.createPortal(children, target)}
+
+ );
+}
+
+function resolveContainer(
+ container: FullscreenPortal.Container | undefined,
+): Element | DocumentFragment | null {
+ if (container == null) {
+ return typeof document !== 'undefined' ? document.body : null;
+ }
+ if (typeof container === 'function') {
+ return container() ?? null;
+ }
+ if ('current' in container) {
+ return container.current ?? null;
+ }
+ return container;
+}
+
+export interface FullscreenPortalProps {
+ /**
+ * Whether to keep the contents mounted in the DOM while the container is
+ * not in fullscreen.
+ * @default false
+ */
+ keepMounted?: boolean | undefined;
+ /**
+ * The element to portal into. Defaults to `document.body`.
+ */
+ container?: FullscreenPortalContainer | undefined;
+ /**
+ * The content of the portal.
+ */
+ children?: React.ReactNode | undefined;
+}
+
+export type FullscreenPortalContainer =
+ | Element
+ | DocumentFragment
+ | React.RefObject
+ | (() => Element | DocumentFragment | null)
+ | null;
+
+export namespace FullscreenPortal {
+ export type Props = FullscreenPortalProps;
+ export type Container = FullscreenPortalContainer;
+}
diff --git a/packages/react/src/fullscreen/portal/FullscreenPortalContext.ts b/packages/react/src/fullscreen/portal/FullscreenPortalContext.ts
new file mode 100644
index 00000000000..c3fd172691d
--- /dev/null
+++ b/packages/react/src/fullscreen/portal/FullscreenPortalContext.ts
@@ -0,0 +1,16 @@
+'use client';
+import * as React from 'react';
+
+/**
+ * Provided by `` so that descendants — chiefly
+ * `` — can detect they are mounted inside a portal and
+ * adapt their behavior (for example, hiding themselves while not in
+ * fullscreen so `keepMounted` content does not leak into the page layout).
+ *
+ * The value is the resolved `keepMounted` flag for parity with
+ * `DialogPortalContext`, even though the Fullscreen container does not need
+ * the value today.
+ *
+ * Returns `undefined` when the consumer is not inside a ``.
+ */
+export const FullscreenPortalContext = React.createContext(undefined);
diff --git a/packages/react/src/fullscreen/root/FullscreenRoot.test.tsx b/packages/react/src/fullscreen/root/FullscreenRoot.test.tsx
new file mode 100644
index 00000000000..23f1484617b
--- /dev/null
+++ b/packages/react/src/fullscreen/root/FullscreenRoot.test.tsx
@@ -0,0 +1,649 @@
+import { expect, vi } from 'vitest';
+import * as React from 'react';
+import { act, fireEvent, screen, flushMicrotasks } from '@mui/internal-test-utils';
+import { Fullscreen } from '@base-ui/react/fullscreen';
+import { Dialog } from '@base-ui/react/dialog';
+import { createRenderer } from '#test-utils';
+import { REASONS } from '../../internals/reasons';
+import { installFullscreenApiStubs, type FullscreenApiStubs } from './fullscreenApiTestUtils';
+
+describe(' ', () => {
+ const { render } = createRenderer();
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ describe('rendering', () => {
+ it('does not render its own DOM element', async () => {
+ const { container } = await render(
+
+
+ ,
+ );
+
+ expect(container.children.length).toBe(1);
+ expect(container.firstElementChild).toBe(screen.getByTestId('container'));
+ });
+ });
+
+ describe('controlled and uncontrolled', () => {
+ it('honors `defaultOpen={false}`', async () => {
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false');
+ expect(screen.getByTestId('container')).toHaveAttribute('data-not-fullscreen');
+ });
+
+ it('toggles state on trigger press while uncontrolled', async () => {
+ const handleOpenChange = vi.fn();
+
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+ const container = screen.getByTestId('container');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ true,
+ expect.objectContaining({ reason: REASONS.triggerPress }),
+ );
+ expect(trigger).toHaveAttribute('aria-pressed', 'true');
+ expect(container).toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.triggerPress }),
+ );
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+ expect(container).toHaveAttribute('data-not-fullscreen');
+ });
+
+ it('does not update internal state or call the API in controlled mode when the parent ignores onOpenChange', async () => {
+ function ControlledFullscreen() {
+ const [open] = React.useState(false);
+ return (
+ undefined}>
+ Toggle
+
+
+ );
+ }
+
+ await render( );
+
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ const container = screen.getByTestId('container');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+ expect(container).toHaveAttribute('data-not-fullscreen');
+ // Because the parent ignored `onOpenChange`, `open` never flipped, so the
+ // browser API is never called either.
+ expect(stubs.request).not.toHaveBeenCalled();
+ });
+
+ it('toggles via the bundled trigger in controlled mode', async () => {
+ function ControlledFullscreen() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ Toggle
+
+
+ );
+ }
+
+ await render( );
+
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ const container = screen.getByTestId('container');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(trigger).toHaveAttribute('aria-pressed', 'true');
+ expect(container).toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+ expect(container).toHaveAttribute('data-not-fullscreen');
+ });
+
+ it('drives the browser API when controlled `open` is flipped from outside', async () => {
+ function ControlledFullscreen() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ setOpen(true)}>
+ External open
+
+ setOpen(false)}>
+ External close
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ const externalOpen = screen.getByRole('button', { name: 'External open' });
+ const externalClose = screen.getByRole('button', { name: 'External close' });
+ const container = screen.getByTestId('container');
+
+ fireEvent.click(externalOpen);
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(container).toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(externalClose);
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(container).toHaveAttribute('data-not-fullscreen');
+ });
+
+ it('reverts `open` and dispatches `reason: none` when `requestFullscreen` is rejected', async () => {
+ stubs.request.mockImplementation(() => Promise.reject(new TypeError('blocked')));
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const handleOpenChange = vi.fn();
+
+ function ControlledFullscreen() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ {
+ setOpen(true);
+ }}
+ >
+ External open
+
+ {
+ setOpen(nextOpen);
+ handleOpenChange(nextOpen, details);
+ }}
+ >
+
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'External open' }));
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.none }),
+ );
+ expect(screen.getByTestId('container')).toHaveAttribute('data-not-fullscreen');
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('`requestFullscreen()` was rejected'),
+ );
+
+ warnSpy.mockRestore();
+ });
+ });
+
+ describe('disabled', () => {
+ it('passes disabled to the trigger via context', async () => {
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+ expect(trigger).toHaveAttribute('data-disabled');
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('does not call requestFullscreen when disabled', async () => {
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+ await flushMicrotasks();
+
+ expect(stubs.request).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('unsupported', () => {
+ it('disables the trigger when fullscreenEnabled is false', async () => {
+ stubs.setEnabled(false);
+
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+ expect(trigger).toHaveAttribute('data-disabled');
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
+ });
+ });
+
+ describe('container unmount', () => {
+ it('resets state and dispatches `onOpenChange` when the container unmounts while in fullscreen', async () => {
+ const handleOpenChange = vi.fn();
+
+ function App({ mounted }: { mounted: boolean }) {
+ return (
+
+ Toggle
+ {mounted && }
+
+ );
+ }
+
+ const { setProps } = await render( );
+
+ const trigger = screen.getByRole('button');
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(trigger).toHaveAttribute('aria-pressed', 'true');
+ handleOpenChange.mockClear();
+
+ await setProps({ mounted: false });
+ await flushMicrotasks();
+
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.none }),
+ );
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+ });
+ });
+
+ describe('Esc bridging', () => {
+ it('exits fullscreen on Escape when nothing else handles the event', async () => {
+ const handleOpenChange = vi.fn();
+
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+ handleOpenChange.mockClear();
+
+ const escEvent = new KeyboardEvent('keydown', {
+ key: 'Escape',
+ bubbles: true,
+ cancelable: true,
+ });
+ await act(async () => {
+ document.dispatchEvent(escEvent);
+ });
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.escapeKey }),
+ );
+ });
+
+ it('routes a Base UI Dialog portal into the fullscreen container so the popup stays visible', async () => {
+ await render(
+
+ Toggle
+
+
+ Open dialog
+
+
+
+ Dialog title
+
+
+
+
+ ,
+ );
+
+ const fullscreenContainer = screen.getByTestId('fullscreen-container');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Open dialog' }));
+ await flushMicrotasks();
+
+ expect(fullscreenContainer.contains(screen.getByRole('dialog'))).toBe(true);
+ });
+
+ it('lets a Base UI Dialog dismiss on Escape without exiting fullscreen', async () => {
+ const handleFullscreenOpenChange = vi.fn();
+ const handleDialogOpenChange = vi.fn();
+
+ await render(
+
+ Toggle
+
+
+ Open dialog
+
+
+
+ Dialog title
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Open dialog' }));
+ await flushMicrotasks();
+
+ handleFullscreenOpenChange.mockClear();
+ handleDialogOpenChange.mockClear();
+ stubs.exit.mockClear();
+
+ const escEvent = new KeyboardEvent('keydown', {
+ key: 'Escape',
+ bubbles: true,
+ cancelable: true,
+ });
+ await act(async () => {
+ document.dispatchEvent(escEvent);
+ });
+ await flushMicrotasks();
+
+ expect(handleDialogOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.escapeKey }),
+ );
+ expect(stubs.exit).not.toHaveBeenCalled();
+ expect(handleFullscreenOpenChange).not.toHaveBeenCalled();
+ });
+
+ it('does not exit fullscreen when an Escape handler calls preventDefault', async () => {
+ const handleOpenChange = vi.fn();
+
+ function App() {
+ React.useEffect(() => {
+ function handleKey(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ }
+ }
+ document.addEventListener('keydown', handleKey);
+ return () => document.removeEventListener('keydown', handleKey);
+ }, []);
+
+ return (
+
+ Toggle
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+ handleOpenChange.mockClear();
+ stubs.exit.mockClear();
+
+ const escEvent = new KeyboardEvent('keydown', {
+ key: 'Escape',
+ bubbles: true,
+ cancelable: true,
+ });
+ await act(async () => {
+ document.dispatchEvent(escEvent);
+ });
+ await flushMicrotasks();
+
+ expect(stubs.exit).not.toHaveBeenCalled();
+ expect(handleOpenChange).not.toHaveBeenCalled();
+ expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ );
+ });
+ });
+
+ describe('external fullscreenchange', () => {
+ it('updates open and dispatches reason=escape-key when the browser exits without our request', async () => {
+ const handleOpenChange = vi.fn();
+
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(trigger).toHaveAttribute('aria-pressed', 'true');
+ handleOpenChange.mockClear();
+
+ await act(async () => {
+ stubs.setActiveElement(null);
+ stubs.dispatchChange();
+ });
+ await flushMicrotasks();
+
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.escapeKey }),
+ );
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+ });
+ });
+
+ describe('target prop', () => {
+ let externalTarget: HTMLElement | null = null;
+
+ beforeEach(() => {
+ externalTarget = document.createElement('section');
+ externalTarget.id = 'external-section';
+ document.body.appendChild(externalTarget);
+ });
+
+ afterEach(() => {
+ if (externalTarget && externalTarget.parentNode) {
+ externalTarget.parentNode.removeChild(externalTarget);
+ }
+ externalTarget = null;
+ });
+
+ it('opens the external element when target is a getter and the trigger is clicked', async () => {
+ await render(
+ externalTarget}>
+ Toggle
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ expect(trigger).toHaveAttribute('aria-controls', 'external-section');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(stubs.request.mock.instances[0]).toBe(externalTarget);
+ expect(trigger).toHaveAttribute('aria-pressed', 'true');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('opens the external element when target is a ref', async () => {
+ function App() {
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ ref.current = externalTarget;
+ }, []);
+ return (
+
+ Toggle
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(stubs.request.mock.instances[0]).toBe(externalTarget);
+ });
+
+ it('opens the external element when target is a ref to an ancestor DOM node', async () => {
+ // Reproduces the common case where the consumer hangs the ref on a
+ // parent element of ``. React attaches that ref AFTER
+ // the descendant's layout effects fire, so eager resolution would see
+ // a null ref. The lazy resolution inside `useFullscreenRoot`'s open-
+ // effect must pick the element up at request time.
+ function App() {
+ const ancestorRef = React.useRef(null);
+ return (
+
+ );
+ }
+
+ await render( );
+ const section = screen.getByTestId('ancestor-section');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(stubs.request.mock.instances[0]).toBe(section);
+ expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute(
+ 'aria-controls',
+ 'ancestor-section',
+ );
+ });
+
+ it('reconciles state when the browser exits fullscreen via Esc', async () => {
+ const handleOpenChange = vi.fn();
+
+ await render(
+ externalTarget} onOpenChange={handleOpenChange}>
+ Toggle
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+ handleOpenChange.mockClear();
+
+ await act(async () => {
+ stubs.setActiveElement(null);
+ stubs.dispatchChange();
+ });
+ await flushMicrotasks();
+
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.escapeKey }),
+ );
+ expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute(
+ 'aria-pressed',
+ 'false',
+ );
+ });
+
+ it('drives the external element from a controlled `open` change', async () => {
+ function App() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ setOpen(true)}>
+ External open
+
+ setOpen(false)}>
+ External close
+
+ externalTarget} />
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'External open' }));
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(stubs.request.mock.instances[0]).toBe(externalTarget);
+
+ fireEvent.click(screen.getByRole('button', { name: 'External close' }));
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/packages/react/src/fullscreen/root/FullscreenRoot.tsx b/packages/react/src/fullscreen/root/FullscreenRoot.tsx
new file mode 100644
index 00000000000..8a49172c414
--- /dev/null
+++ b/packages/react/src/fullscreen/root/FullscreenRoot.tsx
@@ -0,0 +1,151 @@
+'use client';
+import * as React from 'react';
+import { useStableCallback } from '@base-ui/utils/useStableCallback';
+import { useFullscreenRoot, type FullscreenNavigationUI } from './useFullscreenRoot';
+import { FullscreenRootContext } from './FullscreenRootContext';
+import { useExternalFullscreenTarget, type FullscreenTarget } from './useExternalFullscreenTarget';
+import type { BaseUIChangeEventDetails } from '../../internals/createBaseUIEventDetails';
+import { REASONS } from '../../internals/reasons';
+import { FullscreenStore } from '../store/FullscreenStore';
+import { FullscreenHandle } from '../store/FullscreenHandle';
+
+/**
+ * Groups all parts of the fullscreen.
+ * Doesn't render its own HTML element.
+ *
+ * Documentation: [Base UI Fullscreen](https://base-ui.com/react/components/fullscreen)
+ */
+export function FullscreenRoot(componentProps: FullscreenRoot.Props) {
+ const {
+ children,
+ defaultOpen = false,
+ disabled = false,
+ handle,
+ navigationUI = 'auto',
+ onOpenChange: onOpenChangeProp,
+ open,
+ target,
+ } = componentProps;
+
+ const onOpenChange = useStableCallback(onOpenChangeProp);
+
+ const store = FullscreenStore.useStore(handle?.store, {
+ open: defaultOpen,
+ openProp: open,
+ disabled,
+ navigationUI,
+ });
+
+ store.useControlledProp('openProp', open);
+ store.useSyncedValues({ disabled, navigationUI });
+
+ const fullscreen = useFullscreenRoot({
+ store,
+ onOpenChange,
+ target,
+ });
+
+ useExternalFullscreenTarget(store, target, fullscreen);
+
+ const contextValue: FullscreenRootContext = React.useMemo(
+ () => ({
+ ...fullscreen,
+ store,
+ }),
+ [fullscreen, store],
+ );
+
+ return (
+ {children}
+ );
+}
+
+export interface FullscreenRootState {
+ /**
+ * Whether the container is currently displayed in fullscreen.
+ */
+ open: boolean;
+ /**
+ * Whether the component should ignore user interaction.
+ */
+ disabled: boolean;
+ /**
+ * Whether the browser supports the Fullscreen API for the container's owner document.
+ */
+ supported: boolean;
+}
+
+export interface FullscreenRootProps {
+ /**
+ * Whether the container is currently displayed in fullscreen.
+ *
+ * To render an uncontrolled fullscreen, use the `defaultOpen` prop instead.
+ */
+ open?: boolean | undefined;
+ /**
+ * Whether the container is initially displayed in fullscreen.
+ *
+ * The Fullscreen API requires a user gesture to enter fullscreen, so an
+ * initially open value can only be honored after the user interacts with the
+ * page. To render a controlled fullscreen, use the `open` prop instead.
+ * @default false
+ */
+ defaultOpen?: boolean | undefined;
+ /**
+ * Event handler called when the container enters or exits fullscreen.
+ */
+ onOpenChange?:
+ | ((open: boolean, eventDetails: FullscreenRootChangeEventDetails) => void)
+ | undefined;
+ /**
+ * Whether the component should ignore user interaction.
+ * @default false
+ */
+ disabled?: boolean | undefined;
+ /**
+ * Hint to the browser describing how the navigation UI should be presented
+ * while the container is in fullscreen. Forwarded to `Element.requestFullscreen()`.
+ * @default 'auto'
+ */
+ navigationUI?: FullscreenNavigationUI | undefined;
+ /**
+ * A handle to control the fullscreen imperatively or to associate detached
+ * `` components with this root. Create one with
+ * `Fullscreen.createHandle()`.
+ */
+ handle?: FullscreenHandle | undefined;
+ /**
+ * An external element to present in fullscreen instead of
+ * ``. Useful for fullscreening the entire page
+ * (`document.documentElement`) or a sibling DOM node.
+ *
+ * Accepts a callback that returns the element (lazy, SSR-safe) or a
+ * `React.RefObject` pointing at the element.
+ *
+ * `` must not be used together with `target`.
+ */
+ target?: FullscreenTarget | undefined;
+ /**
+ * The content of the fullscreen.
+ */
+ children?: React.ReactNode | undefined;
+}
+
+export type FullscreenRootChangeEventReason =
+ | typeof REASONS.triggerPress
+ | typeof REASONS.closePress
+ | typeof REASONS.escapeKey
+ | typeof REASONS.imperativeAction
+ | typeof REASONS.none;
+
+export type FullscreenRootChangeEventDetails =
+ BaseUIChangeEventDetails;
+
+export namespace FullscreenRoot {
+ export type State = FullscreenRootState;
+ export type Props = FullscreenRootProps;
+ export type ChangeEventReason = FullscreenRootChangeEventReason;
+ export type ChangeEventDetails = FullscreenRootChangeEventDetails;
+ export type NavigationUI = FullscreenNavigationUI;
+ export type Target = FullscreenTarget;
+}
diff --git a/packages/react/src/fullscreen/root/FullscreenRootContext.ts b/packages/react/src/fullscreen/root/FullscreenRootContext.ts
new file mode 100644
index 00000000000..935717ba45d
--- /dev/null
+++ b/packages/react/src/fullscreen/root/FullscreenRootContext.ts
@@ -0,0 +1,25 @@
+'use client';
+import * as React from 'react';
+import type { UseFullscreenRootReturnValue } from './useFullscreenRoot';
+import type { FullscreenStore } from '../store/FullscreenStore';
+
+export interface FullscreenRootContext extends UseFullscreenRootReturnValue {
+ store: FullscreenStore;
+}
+
+export const FullscreenRootContext = React.createContext(
+ undefined,
+);
+
+export function useFullscreenRootContext(optional: true): FullscreenRootContext | undefined;
+export function useFullscreenRootContext(optional?: false): FullscreenRootContext;
+export function useFullscreenRootContext(optional?: boolean) {
+ const context = React.useContext(FullscreenRootContext);
+ if (context === undefined && !optional) {
+ throw new Error(
+ 'Base UI: FullscreenRootContext is missing. Fullscreen parts must be placed within , or provided with a handle.',
+ );
+ }
+
+ return context;
+}
diff --git a/packages/react/src/fullscreen/root/fullscreenApi.ts b/packages/react/src/fullscreen/root/fullscreenApi.ts
new file mode 100644
index 00000000000..daf945ec921
--- /dev/null
+++ b/packages/react/src/fullscreen/root/fullscreenApi.ts
@@ -0,0 +1,89 @@
+import type { FullscreenNavigationUI } from './useFullscreenRoot';
+
+// Older Safari (and some embedded WebKit views) only expose the prefixed
+// Fullscreen API. The non-prefixed variants are tried first, then the prefixed
+// fallbacks. The shapes below describe the legacy surface we support.
+interface PrefixedDocument extends Document {
+ webkitFullscreenElement?: Element | null | undefined;
+ webkitFullscreenEnabled?: boolean | undefined;
+ webkitExitFullscreen?: (() => Promise | void) | undefined;
+}
+
+interface PrefixedElement extends HTMLElement {
+ webkitRequestFullscreen?: ((options?: FullscreenOptions) => Promise | void) | undefined;
+}
+
+/**
+ * Event names that fire on the document when the active fullscreen element changes.
+ * The container subscribes to all of them so prefixed implementations work too.
+ */
+export const FULLSCREEN_CHANGE_EVENTS = ['fullscreenchange', 'webkitfullscreenchange'] as const;
+
+/**
+ * Event names that fire on the document when a fullscreen request is rejected.
+ */
+export const FULLSCREEN_ERROR_EVENTS = ['fullscreenerror', 'webkitfullscreenerror'] as const;
+
+/**
+ * Returns the element currently presented in fullscreen for the given document,
+ * or `null` if none is active.
+ */
+export function getFullscreenElement(doc: Document): Element | null {
+ const prefixed = doc as PrefixedDocument;
+ return doc.fullscreenElement ?? prefixed.webkitFullscreenElement ?? null;
+}
+
+/**
+ * Returns whether the Fullscreen API is enabled for the given document.
+ * Mirrors `Document.fullscreenEnabled` with a webkit fallback.
+ */
+export function isFullscreenEnabled(doc: Document): boolean {
+ const prefixed = doc as PrefixedDocument;
+ if (typeof doc.fullscreenEnabled === 'boolean') {
+ return doc.fullscreenEnabled;
+ }
+ if (typeof prefixed.webkitFullscreenEnabled === 'boolean') {
+ return prefixed.webkitFullscreenEnabled;
+ }
+ return false;
+}
+
+/**
+ * Requests fullscreen for `element`, normalizing the return value to a Promise.
+ * Returns `null` when the API is unavailable on the element.
+ */
+export function requestElementFullscreen(
+ element: HTMLElement,
+ navigationUI: FullscreenNavigationUI,
+): Promise | null {
+ const options: FullscreenOptions = { navigationUI };
+ const prefixed = element as PrefixedElement;
+ if (typeof element.requestFullscreen === 'function') {
+ return Promise.resolve(element.requestFullscreen(options));
+ }
+ if (typeof prefixed.webkitRequestFullscreen === 'function') {
+ // The prefixed API does not accept `FullscreenOptions`.
+ const result = prefixed.webkitRequestFullscreen();
+ return result instanceof Promise ? result : Promise.resolve();
+ }
+ return null;
+}
+
+/**
+ * Exits fullscreen on the given document, normalizing the return value to a Promise.
+ * Returns `null` when no fullscreen exit API is available.
+ */
+export function exitDocumentFullscreen(doc: Document): Promise | null {
+ const prefixed = doc as PrefixedDocument;
+ if (getFullscreenElement(doc) == null) {
+ return null;
+ }
+ if (typeof doc.exitFullscreen === 'function') {
+ return Promise.resolve(doc.exitFullscreen());
+ }
+ if (typeof prefixed.webkitExitFullscreen === 'function') {
+ const result = prefixed.webkitExitFullscreen();
+ return result instanceof Promise ? result : Promise.resolve();
+ }
+ return null;
+}
diff --git a/packages/react/src/fullscreen/root/fullscreenApiTestUtils.ts b/packages/react/src/fullscreen/root/fullscreenApiTestUtils.ts
new file mode 100644
index 00000000000..591c1050538
--- /dev/null
+++ b/packages/react/src/fullscreen/root/fullscreenApiTestUtils.ts
@@ -0,0 +1,142 @@
+import { vi, type MockInstance } from 'vitest';
+
+interface PrototypeSnapshot {
+ hadOwnRequest: boolean;
+ ownRequest: PropertyDescriptor | undefined;
+ hadOwnExit: boolean;
+ ownExit: PropertyDescriptor | undefined;
+ hadOwnElement: boolean;
+ ownElement: PropertyDescriptor | undefined;
+ hadOwnEnabled: boolean;
+ ownEnabled: PropertyDescriptor | undefined;
+}
+
+export interface FullscreenApiStubs {
+ request: MockInstance<(this: HTMLElement, options?: FullscreenOptions) => Promise>;
+ exit: MockInstance<(this: Document) => Promise>;
+ setEnabled: (enabled: boolean) => void;
+ setActiveElement: (element: Element | null) => void;
+ dispatchChange: (doc?: Document) => void;
+ restore: () => void;
+}
+
+function dispatchFullscreenChange(doc: Document) {
+ doc.dispatchEvent(new Event('fullscreenchange'));
+}
+
+let enabled = true;
+let fullscreenElement: Element | null = null;
+
+/**
+ * Installs jsdom-friendly fullscreen API stubs that synchronously update
+ * `document.fullscreenElement` and resolve their promises. The returned `restore`
+ * MUST be called from `afterEach` to clean up prototype mutations.
+ */
+export function installFullscreenApiStubs(): FullscreenApiStubs {
+ const snapshot: PrototypeSnapshot = {
+ hadOwnRequest: Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'requestFullscreen'),
+ ownRequest: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'requestFullscreen'),
+ hadOwnExit: Object.prototype.hasOwnProperty.call(Document.prototype, 'exitFullscreen'),
+ ownExit: Object.getOwnPropertyDescriptor(Document.prototype, 'exitFullscreen'),
+ hadOwnElement: Object.prototype.hasOwnProperty.call(Document.prototype, 'fullscreenElement'),
+ ownElement: Object.getOwnPropertyDescriptor(Document.prototype, 'fullscreenElement'),
+ hadOwnEnabled: Object.prototype.hasOwnProperty.call(Document.prototype, 'fullscreenEnabled'),
+ ownEnabled: Object.getOwnPropertyDescriptor(Document.prototype, 'fullscreenEnabled'),
+ };
+
+ enabled = true;
+ fullscreenElement = null;
+
+ Object.defineProperty(Document.prototype, 'fullscreenEnabled', {
+ configurable: true,
+ get: () => enabled,
+ });
+
+ Object.defineProperty(Document.prototype, 'fullscreenElement', {
+ configurable: true,
+ get: () => fullscreenElement,
+ });
+
+ // Install plain originals first so vitest can spy on them, then override
+ // with the desired implementation. Using `value` writable makes spying work
+ // on jsdom prototypes that don't ship the methods.
+ Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', {
+ configurable: true,
+ writable: true,
+ value() {
+ return Promise.resolve();
+ },
+ });
+ Object.defineProperty(Document.prototype, 'exitFullscreen', {
+ configurable: true,
+ writable: true,
+ value() {
+ return Promise.resolve();
+ },
+ });
+
+ const request = vi
+ .spyOn(HTMLElement.prototype, 'requestFullscreen')
+ .mockImplementation(function fakeRequestFullscreen(this: HTMLElement) {
+ // eslint-disable-next-line consistent-this
+ const target: HTMLElement = this;
+ fullscreenElement = target;
+ dispatchFullscreenChange(target.ownerDocument);
+ return Promise.resolve();
+ });
+
+ const exit = vi
+ .spyOn(Document.prototype, 'exitFullscreen')
+ .mockImplementation(function fakeExitFullscreen(this: Document) {
+ // eslint-disable-next-line consistent-this
+ const target: Document = this;
+ fullscreenElement = null;
+ dispatchFullscreenChange(target);
+ return Promise.resolve();
+ });
+
+ return {
+ request,
+ exit,
+ setEnabled(next: boolean) {
+ enabled = next;
+ },
+ setActiveElement(element: Element | null) {
+ fullscreenElement = element;
+ },
+ dispatchChange(doc: Document = document) {
+ dispatchFullscreenChange(doc);
+ },
+ restore() {
+ request.mockRestore();
+ exit.mockRestore();
+
+ if (snapshot.hadOwnRequest && snapshot.ownRequest) {
+ Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', snapshot.ownRequest);
+ } else {
+ Reflect.deleteProperty(HTMLElement.prototype, 'requestFullscreen');
+ }
+
+ if (snapshot.hadOwnExit && snapshot.ownExit) {
+ Object.defineProperty(Document.prototype, 'exitFullscreen', snapshot.ownExit);
+ } else {
+ Reflect.deleteProperty(Document.prototype, 'exitFullscreen');
+ }
+
+ if (snapshot.hadOwnElement && snapshot.ownElement) {
+ Object.defineProperty(Document.prototype, 'fullscreenElement', snapshot.ownElement);
+ } else {
+ Reflect.deleteProperty(Document.prototype, 'fullscreenElement');
+ }
+
+ if (snapshot.hadOwnEnabled && snapshot.ownEnabled) {
+ Object.defineProperty(Document.prototype, 'fullscreenEnabled', snapshot.ownEnabled);
+ } else {
+ Reflect.deleteProperty(Document.prototype, 'fullscreenEnabled');
+ }
+
+ enabled = true;
+ fullscreenElement = null;
+ },
+ };
+}
diff --git a/packages/react/src/fullscreen/root/stateAttributesMapping.ts b/packages/react/src/fullscreen/root/stateAttributesMapping.ts
new file mode 100644
index 00000000000..f405e882671
--- /dev/null
+++ b/packages/react/src/fullscreen/root/stateAttributesMapping.ts
@@ -0,0 +1,21 @@
+import type { StateAttributesMapping } from '../../internals/getStateAttributesProps';
+import { FullscreenContainerDataAttributes } from '../container/FullscreenContainerDataAttributes';
+import type { FullscreenRootState } from './FullscreenRoot';
+
+const FULLSCREEN_HOOK = {
+ [FullscreenContainerDataAttributes.fullscreen]: '',
+};
+
+const NOT_FULLSCREEN_HOOK = {
+ [FullscreenContainerDataAttributes.notFullscreen]: '',
+};
+
+/**
+ * Shared state attribute mapping for parts that reflect the fullscreen state
+ * via `data-fullscreen` / `data-not-fullscreen` (Container, Trigger, Close).
+ */
+export const fullscreenStateMapping = {
+ open(value) {
+ return value ? FULLSCREEN_HOOK : NOT_FULLSCREEN_HOOK;
+ },
+} satisfies StateAttributesMapping;
diff --git a/packages/react/src/fullscreen/root/useExternalFullscreenTarget.ts b/packages/react/src/fullscreen/root/useExternalFullscreenTarget.ts
new file mode 100644
index 00000000000..9eba52c1a17
--- /dev/null
+++ b/packages/react/src/fullscreen/root/useExternalFullscreenTarget.ts
@@ -0,0 +1,101 @@
+'use client';
+import * as React from 'react';
+import { addEventListener } from '@base-ui/utils/addEventListener';
+import { mergeCleanups } from '@base-ui/utils/mergeCleanups';
+import { ownerDocument } from '@base-ui/utils/owner';
+import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
+import {
+ FULLSCREEN_CHANGE_EVENTS,
+ FULLSCREEN_ERROR_EVENTS,
+ isFullscreenEnabled,
+} from './fullscreenApi';
+import type { FullscreenStore } from '../store/FullscreenStore';
+
+/**
+ * The shape of the `target` prop on ``.
+ *
+ * Accepts either a callback that returns the element (lazy, SSR-safe) or a
+ * `React.RefObject` to the element. The element is resolved on every layout
+ * effect so consumers can return a different element when their state changes.
+ */
+export type FullscreenTarget = (() => Element | null | undefined) | React.RefObject;
+
+/**
+ * Resolves a `FullscreenTarget` to a DOM element. Returns `null` for callbacks
+ * that return nothing, refs that haven't attached yet, or `undefined` targets.
+ *
+ * Exported so other parts of the runtime (like `useFullscreenRoot`'s open-
+ * effect) can lazy-resolve the target at request time, which is necessary
+ * when the consumer passes a ref to an ancestor DOM node.
+ */
+export function resolveTarget(target: FullscreenTarget | undefined): Element | null {
+ if (!target) {
+ return null;
+ }
+ if (typeof target === 'function') {
+ return target() ?? null;
+ }
+ return target.current ?? null;
+}
+
+/**
+ * Wires an external `target` element to the `FullscreenStore`. Mirrors what
+ * `` does internally (populate the container ref, attach
+ * document listeners, mark the API as supported), but for an arbitrary element
+ * that is owned outside the `` subtree.
+ *
+ * When `target` is `undefined`, this hook is a no-op so it can be called
+ * unconditionally from ``.
+ */
+export function useExternalFullscreenTarget(
+ store: FullscreenStore,
+ target: FullscreenTarget | undefined,
+ handlers: {
+ handleFullscreenChange: (event: Event) => void;
+ handleFullscreenError: (event: Event) => void;
+ },
+) {
+ const { handleFullscreenChange, handleFullscreenError } = handlers;
+
+ useIsoLayoutEffect(() => {
+ if (!target) {
+ return undefined;
+ }
+
+ const element = resolveTarget(target);
+ if (!element) {
+ // No element resolved this commit (e.g. SSR, or the consumer's lazy
+ // getter returned null). Mark the root as using an external target so a
+ // sibling `` still throws, and bail.
+ store.set('hasExternalTarget', true);
+ return () => {
+ store.set('hasExternalTarget', false);
+ };
+ }
+
+ const doc = ownerDocument(element);
+
+ store.context.containerRef.current = element as HTMLElement;
+ store.set('hasExternalTarget', true);
+ store.set('supported', isFullscreenEnabled(doc));
+ store.set('containerId', element.id !== '' ? element.id : undefined);
+
+ const cleanups: Array<() => void> = [];
+ for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
+ cleanups.push(addEventListener(doc, eventName, handleFullscreenChange));
+ }
+ for (const eventName of FULLSCREEN_ERROR_EVENTS) {
+ cleanups.push(addEventListener(doc, eventName, handleFullscreenError));
+ }
+ const cleanup = mergeCleanups(...cleanups);
+
+ return () => {
+ cleanup();
+ store.set('hasExternalTarget', false);
+ store.set('containerId', undefined);
+ if (store.context.containerRef.current === element) {
+ store.context.containerRef.current = null;
+ }
+ };
+ }, [target, store, handleFullscreenChange, handleFullscreenError]);
+}
diff --git a/packages/react/src/fullscreen/root/useFullscreenRoot.ts b/packages/react/src/fullscreen/root/useFullscreenRoot.ts
new file mode 100644
index 00000000000..ae1a41e977f
--- /dev/null
+++ b/packages/react/src/fullscreen/root/useFullscreenRoot.ts
@@ -0,0 +1,250 @@
+'use client';
+import * as React from 'react';
+import { useStableCallback } from '@base-ui/utils/useStableCallback';
+import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
+import { ownerDocument } from '@base-ui/utils/owner';
+import { warn } from '@base-ui/utils/warn';
+import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
+import { REASONS } from '../../internals/reasons';
+import {
+ getFullscreenElement,
+ isFullscreenEnabled,
+ requestElementFullscreen,
+ exitDocumentFullscreen,
+ FULLSCREEN_CHANGE_EVENTS,
+ FULLSCREEN_ERROR_EVENTS,
+} from './fullscreenApi';
+import { installFullscreenEscapeBridge } from '../escapeBridge';
+import { resolveTarget, type FullscreenTarget } from './useExternalFullscreenTarget';
+import type { FullscreenStore } from '../store/FullscreenStore';
+
+/**
+ * Wires the Fullscreen store to the browser Fullscreen API.
+ *
+ * Subscribes to `open` from the store and reactively calls
+ * `requestFullscreen()` / `exitFullscreen()` from a layout effect so that the
+ * call lands in the same JS task as the user gesture that originated the
+ * state change (preserving the document's transient activation).
+ *
+ * Returns the handlers that the Container component must attach to the
+ * document and to its lifecycle so the store stays in sync with the browser.
+ */
+export function useFullscreenRoot(
+ parameters: UseFullscreenRootParameters,
+): UseFullscreenRootReturnValue {
+ const { store, onOpenChange, target } = parameters;
+
+ // Installed lazily from a layout effect (rather than at module evaluation
+ // time) because the package declares `sideEffects: false`; bundlers would
+ // otherwise drop a top-level install call from the namespace re-export.
+ // The function itself is idempotent and SSR-safe, so calling it from every
+ // `` mount is a no-op after the first one.
+ useIsoLayoutEffect(() => {
+ installFullscreenEscapeBridge();
+ }, []);
+
+ store.useContextCallback('onOpenChange', onOpenChange);
+
+ const open = store.useState('open');
+ const navigationUI = store.useState('navigationUI');
+ const containerRef = store.context.containerRef;
+
+ // Tracks the latest in-flight request promise so we can ignore stale
+ // rejections from a request that was superseded by a newer state change
+ // (e.g. the user toggled twice in quick succession).
+ const requestPromiseRef = React.useRef | null>(null);
+
+ // Tracks the owner document we last observed a container in. Used when
+ // closing a fullscreen view whose container has already been unmounted (for
+ // example because `` returned `null` on the same commit
+ // that flipped `open` to `false`); we still need to call `exitFullscreen()`
+ // on the right document.
+ const ownerDocumentRef = React.useRef(null);
+
+ // Reactive bridge between the store's `open` and the browser Fullscreen API.
+ //
+ // Running this as a layout effect (instead of a plain `useEffect`) is what
+ // makes controlled mode and detached/imperative triggers work end-to-end.
+ // React commits a state update from a user-initiated event handler
+ // synchronously in the same JS task as the event, and the layout effect runs
+ // before the browser yields. As long as the `open={true}` change is initiated
+ // from a user gesture (or a timer that still inherits transient activation),
+ // the document's transient activation is still valid when
+ // `requestFullscreen()` is called here, satisfying the spec's user-activation
+ // requirement.
+ //
+ // If the change is initiated outside of a user gesture (timer beyond the
+ // activation window, network response, programmatic `defaultOpen`, etc.),
+ // the browser will reject the promise; we revert state via the `.catch`
+ // below so consumers see the request fail through
+ // `onOpenChange(false, { reason: 'none' })`.
+ useIsoLayoutEffect(() => {
+ let container = containerRef.current;
+
+ // Lazy-resolve the external `target` prop if `useExternalFullscreenTarget`
+ // could not pin down the element on its initial commit (most commonly
+ // because the consumer passed a ref to an ancestor DOM node — that ref
+ // gets attached after our descendant layout effects fire). By the time
+ // this effect re-runs in response to `open` flipping or another commit,
+ // the parent's ref is guaranteed to be populated.
+ if (!container && target) {
+ const element = resolveTarget(target);
+ if (element) {
+ container = element as HTMLElement;
+ containerRef.current = container;
+ const elementId = (element as HTMLElement).id;
+ const nextContainerId = elementId !== '' ? elementId : undefined;
+ if (store.select('containerId') !== nextContainerId) {
+ store.set('containerId', nextContainerId);
+ }
+ }
+ }
+
+ if (container) {
+ ownerDocumentRef.current = ownerDocument(container);
+ }
+ const doc = ownerDocumentRef.current;
+
+ if (open) {
+ // Need a live container to enter fullscreen. If ``
+ // hasn't mounted it yet (or it was conditionally rendered out), bail
+ // and let the next commit (with the container present) drive the call.
+ if (!container) {
+ return;
+ }
+ const isInFullscreen = doc ? getFullscreenElement(doc) === container : false;
+ if (isInFullscreen) {
+ return;
+ }
+ const promise = requestElementFullscreen(container, navigationUI);
+ requestPromiseRef.current = promise;
+ if (promise) {
+ promise.catch(() => {
+ // Ignore the rejection if the user changed state again before we
+ // observed it (e.g. they toggled twice in quick succession).
+ if (requestPromiseRef.current !== promise) {
+ return;
+ }
+ if (process.env.NODE_ENV !== 'production') {
+ warn(
+ '`requestFullscreen()` was rejected. The browser may have blocked the request because it was not initiated by a user gesture, or because fullscreen is not supported.',
+ );
+ }
+ store.setOpen(false, createChangeEventDetails(REASONS.none));
+ });
+ }
+ } else if (doc && getFullscreenElement(doc)) {
+ // Exiting only depends on the document, not the container. This lets
+ // us close cleanly even when the container has just unmounted (e.g.
+ // because `` re-rendered to `null` on the same
+ // commit that flipped `open` to `false`).
+ const promise = exitDocumentFullscreen(doc);
+ requestPromiseRef.current = promise;
+ if (promise) {
+ promise.catch(() => {
+ // Restoring fullscreen after a failed exit would be jarring. The
+ // next `fullscreenchange` event will reconcile state if needed.
+ });
+ }
+ }
+ }, [open, navigationUI, store, containerRef, target]);
+
+ // Handles browser-initiated state changes (most commonly the Esc key, but
+ // also browser exit affordances or the active element being removed). When
+ // the browser state and our state already match, the event is a no-op (it
+ // fired in response to one of our own request/exit calls). The document-
+ // level Esc bridge in `escapeBridge.ts` ensures Esc reaches popup handlers
+ // first; this listener only reconciles the React state with the browser.
+ const handleFullscreenChange = useStableCallback((event: Event) => {
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+ const doc = ownerDocument(container);
+ const isInFullscreen = getFullscreenElement(doc) === container;
+ const currentOpen = store.select('open');
+
+ if (isInFullscreen === currentOpen) {
+ return;
+ }
+
+ const reason = isInFullscreen ? REASONS.none : REASONS.escapeKey;
+ store.setOpen(isInFullscreen, createChangeEventDetails(reason, event));
+ });
+
+ const handleFullscreenError = useStableCallback(() => {
+ if (process.env.NODE_ENV !== 'production') {
+ warn(
+ 'A `fullscreenerror` event was dispatched on the document. The browser refused to enter fullscreen for the requested element.',
+ );
+ }
+ // The promise rejection from `requestFullscreen()` is the source of truth
+ // for reverting state, so this handler is informational only.
+ });
+
+ // Called when the container element unmounts. Per the Fullscreen spec, the
+ // browser auto-exits fullscreen when the active element is removed, but the
+ // `fullscreenchange` event fires after our document listeners are gone. We
+ // sync state here so consumers (especially controlled mode) don't get stuck
+ // with a stale `open: true`.
+ const handleContainerUnmount = useStableCallback(() => {
+ requestPromiseRef.current = null;
+ if (!store.select('open')) {
+ return;
+ }
+ store.setOpen(false, createChangeEventDetails(REASONS.none));
+ });
+
+ return React.useMemo(
+ () => ({
+ handleFullscreenChange,
+ handleFullscreenError,
+ handleContainerUnmount,
+ }),
+ [handleFullscreenChange, handleFullscreenError, handleContainerUnmount],
+ );
+}
+
+// Re-export so callers can detect support without reaching into internals.
+export { isFullscreenEnabled, FULLSCREEN_CHANGE_EVENTS, FULLSCREEN_ERROR_EVENTS };
+
+export type FullscreenNavigationUI = 'auto' | 'show' | 'hide';
+
+export interface UseFullscreenRootParameters {
+ /**
+ * The store managing the fullscreen state. Created by ``
+ * (or shared via a `Fullscreen.Handle`).
+ */
+ store: FullscreenStore;
+ /**
+ * Stable callback invoked when the open state changes.
+ */
+ onOpenChange: (
+ open: boolean,
+ eventDetails: import('./FullscreenRoot').FullscreenRoot.ChangeEventDetails,
+ ) => void;
+ /**
+ * Optional external `target` element to fullscreen instead of
+ * ``. When provided, the open-effect lazy-resolves
+ * it at request time, which handles refs to ancestor DOM nodes whose
+ * attachment lands after our descendant layout effects fire.
+ */
+ target?: FullscreenTarget | undefined;
+}
+
+export interface UseFullscreenRootReturnValue {
+ /**
+ * Handler for the document's `fullscreenchange` event. Called by the container.
+ */
+ handleFullscreenChange: (event: Event) => void;
+ /**
+ * Handler for the document's `fullscreenerror` event. Called by the container.
+ */
+ handleFullscreenError: (event: Event) => void;
+ /**
+ * Called by the container when it unmounts. Resets state if we believe the
+ * container is still in fullscreen so consumers stay in sync after the
+ * browser auto-exits fullscreen on element removal.
+ */
+ handleContainerUnmount: () => void;
+}
diff --git a/packages/react/src/fullscreen/store/FullscreenHandle.test.tsx b/packages/react/src/fullscreen/store/FullscreenHandle.test.tsx
new file mode 100644
index 00000000000..e4ba8f22b7c
--- /dev/null
+++ b/packages/react/src/fullscreen/store/FullscreenHandle.test.tsx
@@ -0,0 +1,195 @@
+import { expect, vi } from 'vitest';
+import * as React from 'react';
+import { act, fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils';
+import { Fullscreen } from '@base-ui/react/fullscreen';
+import { createRenderer } from '#test-utils';
+import { REASONS } from '../../internals/reasons';
+import { installFullscreenApiStubs, type FullscreenApiStubs } from '../root/fullscreenApiTestUtils';
+
+describe('Fullscreen.createHandle', () => {
+ const { render } = createRenderer();
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ it('returns a handle with open(), close(), and isOpen', () => {
+ const handle = Fullscreen.createHandle();
+ expect(typeof handle.open).toBe('function');
+ expect(typeof handle.close).toBe('function');
+ expect(handle.isOpen).toBe(false);
+ });
+
+ it('opens fullscreen when `handle.open()` is called from a click handler', async () => {
+ const handle = Fullscreen.createHandle();
+ const handleOpenChange = vi.fn();
+
+ function App() {
+ return (
+
+ handle.open()}>
+ Imperative open
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Imperative open' }));
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(handle.isOpen).toBe(true);
+ expect(screen.getByTestId('container')).toHaveAttribute('data-fullscreen');
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ true,
+ expect.objectContaining({ reason: REASONS.imperativeAction }),
+ );
+ });
+
+ it('closes fullscreen when `handle.close()` is called', async () => {
+ const handle = Fullscreen.createHandle();
+ const handleOpenChange = vi.fn();
+
+ function App() {
+ return (
+
+ handle.open()}>
+ Imperative open
+
+ handle.close()}>
+ Imperative close
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Imperative open' }));
+ await flushMicrotasks();
+ expect(handle.isOpen).toBe(true);
+ handleOpenChange.mockClear();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Imperative close' }));
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(handle.isOpen).toBe(false);
+ expect(screen.getByTestId('container')).toHaveAttribute('data-not-fullscreen');
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.imperativeAction }),
+ );
+ });
+
+ it('reverts state and dispatches `reason: none` when an imperative open is rejected', async () => {
+ stubs.request.mockImplementation(() => Promise.reject(new TypeError('blocked')));
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const handle = Fullscreen.createHandle();
+ const handleOpenChange = vi.fn();
+
+ function App() {
+ return (
+
+ handle.open()}>
+ Imperative open
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Imperative open' }));
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(handleOpenChange).toHaveBeenLastCalledWith(
+ false,
+ expect.objectContaining({ reason: REASONS.none }),
+ );
+ expect(handle.isOpen).toBe(false);
+ expect(screen.getByTestId('container')).toHaveAttribute('data-not-fullscreen');
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('`requestFullscreen()` was rejected'),
+ );
+
+ warnSpy.mockRestore();
+ });
+
+ it('reflects browser-initiated state changes (e.g. Esc) in `handle.isOpen`', async () => {
+ const handle = Fullscreen.createHandle();
+
+ function App() {
+ return (
+
+ handle.open()}>
+ Imperative open
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Imperative open' }));
+ await flushMicrotasks();
+ expect(handle.isOpen).toBe(true);
+
+ await act(async () => {
+ stubs.setActiveElement(null);
+ stubs.dispatchChange();
+ });
+ await flushMicrotasks();
+
+ expect(handle.isOpen).toBe(false);
+ });
+
+ it('warns in development when `open(triggerId)` is given an unknown id', async () => {
+ const handle = Fullscreen.createHandle();
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ function App() {
+ return (
+
+ handle.open('does-not-exist')}>
+ Imperative open
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Imperative open' }));
+ await flushMicrotasks();
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('No trigger found with id "does-not-exist"'),
+ );
+
+ warnSpy.mockRestore();
+ });
+});
diff --git a/packages/react/src/fullscreen/store/FullscreenHandle.ts b/packages/react/src/fullscreen/store/FullscreenHandle.ts
new file mode 100644
index 00000000000..78e2f90d6ac
--- /dev/null
+++ b/packages/react/src/fullscreen/store/FullscreenHandle.ts
@@ -0,0 +1,85 @@
+import { FullscreenStore } from './FullscreenStore';
+import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
+import { REASONS } from '../../internals/reasons';
+
+/**
+ * A handle to control a Fullscreen imperatively and to associate detached
+ * triggers with it.
+ *
+ * Pass the handle to `` and to any detached
+ * `` rendered outside the root. Calling
+ * `open()` from a user-gesture handler enters fullscreen the same way a
+ * trigger press would.
+ */
+export class FullscreenHandle {
+ /**
+ * Internal store holding the fullscreen state.
+ * @internal
+ */
+ public readonly store: FullscreenStore;
+
+ constructor(store?: FullscreenStore) {
+ this.store = store ?? new FullscreenStore();
+ }
+
+ /**
+ * Enters fullscreen and associates the change with the trigger with the given
+ * id, if provided.
+ *
+ * `requestFullscreen()` requires the document to have transient activation,
+ * so this method must be called from within a user-gesture event handler
+ * (click, keydown, etc.) or from a task that still inherits a recent
+ * activation. Calls outside that window will be rejected by the browser; the
+ * rejection is caught and reverts state via `onOpenChange(false, { reason: 'none' })`.
+ *
+ * @param triggerId ID of the trigger to associate with the change. If null
+ * or omitted, the change is dispatched without an associated trigger.
+ */
+ open(triggerId: string | null = null) {
+ const triggerElement =
+ triggerId != null
+ ? (this.store.context.triggerElements.getById(triggerId) as HTMLElement | undefined)
+ : undefined;
+
+ if (process.env.NODE_ENV !== 'production') {
+ if (triggerId != null && !triggerElement) {
+ console.warn(
+ `Base UI: FullscreenHandle.open: No trigger found with id "${triggerId}". The fullscreen will open, but no trigger will be associated with the change.`,
+ );
+ }
+ }
+
+ this.store.setOpen(
+ true,
+ createChangeEventDetails(REASONS.imperativeAction, undefined, triggerElement),
+ );
+ }
+
+ /**
+ * Exits fullscreen.
+ *
+ * Unlike `open()`, exiting fullscreen does not require a user gesture and
+ * can be called at any time.
+ */
+ close() {
+ this.store.setOpen(
+ false,
+ createChangeEventDetails(REASONS.imperativeAction, undefined, undefined),
+ );
+ }
+
+ /**
+ * Indicates whether the container is currently in fullscreen.
+ */
+ get isOpen() {
+ return this.store.select('open');
+ }
+}
+
+/**
+ * Creates a new handle to control a Fullscreen imperatively or to connect a
+ * `` with detached `` components.
+ */
+export function createFullscreenHandle(): FullscreenHandle {
+ return new FullscreenHandle();
+}
diff --git a/packages/react/src/fullscreen/store/FullscreenStore.ts b/packages/react/src/fullscreen/store/FullscreenStore.ts
new file mode 100644
index 00000000000..93d8811094e
--- /dev/null
+++ b/packages/react/src/fullscreen/store/FullscreenStore.ts
@@ -0,0 +1,190 @@
+'use client';
+import * as React from 'react';
+import { createSelector, ReactStore } from '@base-ui/utils/store';
+import { useRefWithInit } from '@base-ui/utils/useRefWithInit';
+import { PopupTriggerMap } from '../../utils/popups/popupTriggerMap';
+import type { FullscreenRoot } from '../root/FullscreenRoot';
+import type { FullscreenNavigationUI } from '../root/useFullscreenRoot';
+
+/**
+ * Reactive state held by `FullscreenStore`.
+ *
+ * Most fields are tracked reactively so detached triggers can subscribe to
+ * `open`, `disabled`, `supported`, etc. without going through context.
+ */
+export type FullscreenStoreState = {
+ /**
+ * Whether the container is currently in fullscreen (internal state).
+ */
+ open: boolean;
+ /**
+ * Whether the container is currently in fullscreen (external prop).
+ */
+ readonly openProp: boolean | undefined;
+ /**
+ * ID of the trigger element that activated the fullscreen, if any.
+ */
+ activeTriggerId: string | null;
+ /**
+ * The trigger DOM element that activated the fullscreen, if any.
+ */
+ activeTriggerElement: Element | null;
+ /**
+ * Whether the Fullscreen API is supported by the container's owner document.
+ */
+ supported: boolean;
+ /**
+ * Whether the component should ignore user interaction.
+ */
+ disabled: boolean;
+ /**
+ * Hint forwarded to `Element.requestFullscreen()`.
+ */
+ navigationUI: FullscreenNavigationUI;
+ /**
+ * The id of the container element. Used by the trigger's `aria-controls`.
+ */
+ containerId: string | undefined;
+ /**
+ * Whether the root is using an external `target` element instead of a
+ * ``. Set by `useExternalFullscreenTarget` and used by
+ * `` to detect the misuse of being rendered alongside a
+ * `target` prop.
+ */
+ hasExternalTarget: boolean;
+};
+
+/**
+ * Non-reactive context held by `FullscreenStore`.
+ */
+export type FullscreenStoreContext = {
+ /**
+ * Reference to the container element that goes fullscreen.
+ */
+ readonly containerRef: React.MutableRefObject;
+ /**
+ * Map of registered trigger elements (used by detached triggers).
+ */
+ readonly triggerElements: PopupTriggerMap;
+ /**
+ * Callback fired when the open state changes.
+ */
+ onOpenChange?:
+ | ((open: boolean, eventDetails: FullscreenRoot.ChangeEventDetails) => void)
+ | undefined;
+};
+
+function getOpen(state: FullscreenStoreState) {
+ return state.openProp ?? state.open;
+}
+
+const activeTriggerIdSelector = createSelector(
+ (state: FullscreenStoreState) => state.activeTriggerId,
+);
+
+const selectors = {
+ open: createSelector(getOpen),
+ supported: createSelector((state: FullscreenStoreState) => state.supported),
+ disabled: createSelector((state: FullscreenStoreState) => state.disabled),
+ navigationUI: createSelector((state: FullscreenStoreState) => state.navigationUI),
+ containerId: createSelector((state: FullscreenStoreState) => state.containerId),
+ hasExternalTarget: createSelector((state: FullscreenStoreState) => state.hasExternalTarget),
+ activeTriggerId: activeTriggerIdSelector,
+ activeTriggerElement: createSelector((state: FullscreenStoreState) => state.activeTriggerElement),
+ /**
+ * Whether the trigger with the given id is currently the active trigger.
+ */
+ isTriggerActive: createSelector(
+ (state: FullscreenStoreState, triggerId: string | undefined) =>
+ triggerId !== undefined && activeTriggerIdSelector(state) === triggerId,
+ ),
+ /**
+ * Whether the container is open and was activated by the trigger with the given id.
+ */
+ isOpenedByTrigger: createSelector(
+ (state: FullscreenStoreState, triggerId: string | undefined) =>
+ triggerId !== undefined && activeTriggerIdSelector(state) === triggerId && getOpen(state),
+ ),
+};
+
+export type FullscreenStoreSelectors = typeof selectors;
+
+export class FullscreenStore extends ReactStore<
+ FullscreenStoreState,
+ FullscreenStoreContext,
+ FullscreenStoreSelectors
+> {
+ constructor(initialState?: Partial) {
+ super(
+ createInitialState(initialState),
+ {
+ containerRef: { current: null },
+ triggerElements: new PopupTriggerMap(),
+ onOpenChange: undefined,
+ },
+ selectors,
+ );
+ }
+
+ /**
+ * Updates the open state, dispatching `onOpenChange` first.
+ *
+ * The actual browser API call (`requestFullscreen` / `exitFullscreen`) is
+ * driven by the layout effect inside `useFullscreenRoot`, which subscribes
+ * to the `open` selector and reacts to changes here.
+ */
+ public setOpen = (nextOpen: boolean, eventDetails: FullscreenRoot.ChangeEventDetails) => {
+ this.context.onOpenChange?.(nextOpen, eventDetails);
+
+ if (eventDetails.isCanceled) {
+ return;
+ }
+
+ const updatedState: Partial = {
+ open: nextOpen,
+ };
+
+ if (nextOpen) {
+ const newTriggerId = eventDetails.trigger?.id ?? null;
+ updatedState.activeTriggerId = newTriggerId;
+ updatedState.activeTriggerElement = eventDetails.trigger ?? null;
+ }
+ // We intentionally do not clear `activeTriggerId` on close so the last
+ // active trigger keeps its `data-fullscreen` until a new one takes over.
+
+ this.update(updatedState);
+ };
+
+ /**
+ * Returns either the externally provided store (e.g. from a `Fullscreen.Handle`)
+ * or a freshly created internal store seeded with the given initial state.
+ */
+ static useStore(
+ externalStore: FullscreenStore | undefined,
+ initialState?: Partial,
+ ) {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const internalStore = useRefWithInit(() => {
+ return new FullscreenStore(initialState);
+ }).current;
+
+ return externalStore ?? internalStore;
+ }
+}
+
+function createInitialState(
+ initialState: Partial = {},
+): FullscreenStoreState {
+ return {
+ open: false,
+ openProp: undefined,
+ activeTriggerId: null,
+ activeTriggerElement: null,
+ supported: true,
+ disabled: false,
+ navigationUI: 'auto',
+ containerId: undefined,
+ hasExternalTarget: false,
+ ...initialState,
+ };
+}
diff --git a/packages/react/src/fullscreen/trigger/FullscreenTrigger.detached.test.tsx b/packages/react/src/fullscreen/trigger/FullscreenTrigger.detached.test.tsx
new file mode 100644
index 00000000000..b433fd33649
--- /dev/null
+++ b/packages/react/src/fullscreen/trigger/FullscreenTrigger.detached.test.tsx
@@ -0,0 +1,155 @@
+import { expect, vi } from 'vitest';
+import * as React from 'react';
+import { fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils';
+import { Fullscreen } from '@base-ui/react/fullscreen';
+import { createRenderer } from '#test-utils';
+import { installFullscreenApiStubs, type FullscreenApiStubs } from '../root/fullscreenApiTestUtils';
+
+describe(' (detached)', () => {
+ const { render } = createRenderer();
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ it('opens fullscreen when a detached trigger is clicked', async () => {
+ const handle = Fullscreen.createHandle();
+
+ function App() {
+ return (
+
+ Toggle
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ const trigger = screen.getByRole('button', { name: 'Toggle' });
+ const container = screen.getByTestId('container');
+
+ expect(trigger).toHaveAttribute('aria-controls', container.getAttribute('id'));
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(stubs.request).toHaveBeenCalledOnce();
+ expect(trigger).toHaveAttribute('aria-pressed', 'true');
+ expect(trigger).toHaveAttribute('data-fullscreen');
+ expect(container).toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(stubs.exit).toHaveBeenCalledOnce();
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+ expect(container).toHaveAttribute('data-not-fullscreen');
+ });
+
+ it('marks only the active trigger with `data-fullscreen` when multiple detached triggers share a handle', async () => {
+ const handle = Fullscreen.createHandle();
+
+ function App() {
+ return (
+
+
+ Trigger A
+
+
+ Trigger B
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ const triggerA = screen.getByRole('button', { name: 'Trigger A' });
+ const triggerB = screen.getByRole('button', { name: 'Trigger B' });
+
+ fireEvent.click(triggerA);
+ await flushMicrotasks();
+
+ expect(triggerA).toHaveAttribute('data-fullscreen');
+ expect(triggerA).toHaveAttribute('aria-pressed', 'true');
+ expect(triggerB).toHaveAttribute('data-not-fullscreen');
+ expect(triggerB).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('opens fullscreen via `handle.open(triggerId)` and marks the corresponding trigger as active', async () => {
+ const handle = Fullscreen.createHandle();
+
+ function App() {
+ return (
+
+
+ Trigger A
+
+
+ Trigger B
+
+ handle.open('trigger-b')}>
+ Imperative open via B
+
+
+
+
+
+ );
+ }
+
+ await render( );
+
+ const triggerA = screen.getByRole('button', { name: 'Trigger A' });
+ const triggerB = screen.getByRole('button', { name: 'Trigger B' });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Imperative open via B' }));
+ await flushMicrotasks();
+
+ expect(triggerB).toHaveAttribute('data-fullscreen');
+ expect(triggerB).toHaveAttribute('aria-pressed', 'true');
+ expect(triggerA).toHaveAttribute('data-not-fullscreen');
+ expect(triggerA).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('throws when used without a handle and outside ', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ let caught: unknown = null;
+
+ class Boundary extends React.Component<{ children: React.ReactNode }, { error: unknown }> {
+ state = { error: null };
+
+ static getDerivedStateFromError(error: unknown) {
+ caught = error;
+ return { error };
+ }
+
+ render() {
+ return this.state.error ? null : this.props.children;
+ }
+ }
+
+ await render(
+
+ Detached
+ ,
+ );
+
+ expect(caught).toBeInstanceOf(Error);
+ expect((caught as Error).message).toMatch(//);
+
+ errorSpy.mockRestore();
+ });
+});
diff --git a/packages/react/src/fullscreen/trigger/FullscreenTrigger.test.tsx b/packages/react/src/fullscreen/trigger/FullscreenTrigger.test.tsx
new file mode 100644
index 00000000000..4a1db0c797e
--- /dev/null
+++ b/packages/react/src/fullscreen/trigger/FullscreenTrigger.test.tsx
@@ -0,0 +1,161 @@
+import { expect, vi } from 'vitest';
+import * as React from 'react';
+import { fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils';
+import { Fullscreen } from '@base-ui/react/fullscreen';
+import { createRenderer, describeConformance } from '#test-utils';
+import { installFullscreenApiStubs, type FullscreenApiStubs } from '../root/fullscreenApiTestUtils';
+
+describe(' ', () => {
+ const { render } = createRenderer();
+
+ describeConformance( , () => ({
+ refInstanceof: window.HTMLButtonElement,
+ testComponentPropWith: 'button',
+ button: true,
+ render: (node) => {
+ return render({node} );
+ },
+ }));
+
+ describe('ARIA attributes', () => {
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ it('points aria-controls at the container element', async () => {
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+ const container = screen.getByTestId('container');
+
+ expect(trigger).toHaveAttribute('aria-controls', container.getAttribute('id'));
+ expect(trigger).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('honors a manual `id` on the container', async () => {
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('aria-controls', 'custom-fullscreen');
+ });
+ });
+
+ describe('data attributes', () => {
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ it('reflects the fullscreen state with `data-fullscreen` and `data-not-fullscreen`', async () => {
+ await render(
+
+ Toggle
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button');
+ expect(trigger).toHaveAttribute('data-not-fullscreen');
+ expect(trigger).not.toHaveAttribute('data-fullscreen');
+
+ fireEvent.click(trigger);
+ await flushMicrotasks();
+
+ expect(trigger).toHaveAttribute('data-fullscreen');
+ expect(trigger).not.toHaveAttribute('data-not-fullscreen');
+ });
+ });
+
+ describe('render prop state', () => {
+ let stubs: FullscreenApiStubs;
+
+ beforeEach(() => {
+ stubs = installFullscreenApiStubs();
+ });
+
+ afterEach(() => {
+ stubs.restore();
+ });
+
+ it('passes the fullscreen state to render, className, and style callbacks', async () => {
+ const renderSpy = vi.fn();
+ const classNameSpy = vi.fn().mockReturnValue('trigger-class');
+ const styleSpy = vi.fn().mockReturnValue({ color: 'red' });
+
+ await render(
+
+ {
+ renderSpy(state);
+ return (
+
+ Toggle
+
+ );
+ }}
+ />
+
+ ,
+ );
+
+ const initialState = renderSpy.mock.calls.at(-1)?.[0];
+ expect(initialState).toEqual({ open: false, disabled: false, supported: true });
+ expect(classNameSpy).toHaveBeenLastCalledWith(initialState);
+ expect(styleSpy).toHaveBeenLastCalledWith(initialState);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+ await flushMicrotasks();
+
+ const openState = renderSpy.mock.calls.at(-1)?.[0];
+ expect(openState).toEqual({ open: true, disabled: false, supported: true });
+ expect(classNameSpy).toHaveBeenLastCalledWith(openState);
+ expect(styleSpy).toHaveBeenLastCalledWith(openState);
+ });
+
+ it('reports `disabled: true` in render-prop state when the API is unsupported', async () => {
+ stubs.setEnabled(false);
+ const renderSpy = vi.fn();
+
+ await render(
+
+ {
+ renderSpy(state);
+ return (
+
+ Toggle
+
+ );
+ }}
+ />
+
+ ,
+ );
+
+ const state = renderSpy.mock.calls.at(-1)?.[0];
+ expect(state).toEqual({ open: false, disabled: true, supported: false });
+ });
+ });
+});
diff --git a/packages/react/src/fullscreen/trigger/FullscreenTrigger.tsx b/packages/react/src/fullscreen/trigger/FullscreenTrigger.tsx
new file mode 100644
index 00000000000..cf406ae1613
--- /dev/null
+++ b/packages/react/src/fullscreen/trigger/FullscreenTrigger.tsx
@@ -0,0 +1,122 @@
+'use client';
+import * as React from 'react';
+import { useStableCallback } from '@base-ui/utils/useStableCallback';
+import { useRenderElement } from '../../internals/useRenderElement';
+import { BaseUIComponentProps, NativeButtonProps } from '../../internals/types';
+import { useButton } from '../../internals/use-button';
+import { useBaseUiId } from '../../internals/useBaseUiId';
+import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
+import { REASONS } from '../../internals/reasons';
+import { useFullscreenRootContext } from '../root/FullscreenRootContext';
+import { type FullscreenRootState } from '../root/FullscreenRoot';
+import { fullscreenStateMapping } from '../root/stateAttributesMapping';
+import { FullscreenHandle } from '../store/FullscreenHandle';
+import { useFullscreenTriggerRegistration } from './useFullscreenTriggerRegistration';
+
+/**
+ * A button that toggles the fullscreen state of the container.
+ * Renders a `` element.
+ *
+ * Documentation: [Base UI Fullscreen](https://base-ui.com/react/components/fullscreen)
+ */
+export const FullscreenTrigger = React.forwardRef(function FullscreenTrigger(
+ componentProps: FullscreenTrigger.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ className,
+ disabled: disabledProp,
+ handle,
+ id: idProp,
+ nativeButton = true,
+ render,
+ style,
+ ...elementProps
+ } = componentProps;
+
+ const rootContext = useFullscreenRootContext(true);
+ const store = handle?.store ?? rootContext?.store;
+ if (!store) {
+ throw new Error(
+ 'Base UI: must be used within or provided with a handle.',
+ );
+ }
+
+ const thisTriggerId = useBaseUiId(idProp);
+ const containerId = store.useState('containerId');
+ const supported = store.useState('supported');
+ const contextDisabled = store.useState('disabled');
+ const isOpenedByThisTrigger = store.useState('isOpenedByTrigger', thisTriggerId);
+
+ // The trigger is unusable while the Fullscreen API is not supported by the
+ // owner document, so we surface that as `disabled` to assistive tech and
+ // visually via the standard `data-disabled` attribute.
+ const disabled = (disabledProp ?? contextDisabled) || !supported;
+
+ const triggerElementRef = React.useRef(null);
+ const registerTrigger = useFullscreenTriggerRegistration(thisTriggerId, store);
+
+ const { getButtonProps, buttonRef } = useButton({
+ disabled,
+ focusableWhenDisabled: true,
+ native: nativeButton,
+ });
+
+ const handleClick = useStableCallback((event: React.MouseEvent) => {
+ if (disabled) {
+ return;
+ }
+ const next = !store.select('open');
+ const triggerElement = triggerElementRef.current ?? undefined;
+ store.setOpen(
+ next,
+ createChangeEventDetails(REASONS.triggerPress, event.nativeEvent, triggerElement),
+ );
+ });
+
+ const state: FullscreenTriggerState = React.useMemo(
+ () => ({
+ open: isOpenedByThisTrigger,
+ disabled,
+ supported,
+ }),
+ [isOpenedByThisTrigger, disabled, supported],
+ );
+
+ return useRenderElement('button', componentProps, {
+ state,
+ ref: [forwardedRef, buttonRef, registerTrigger, triggerElementRef],
+ props: [
+ {
+ 'aria-controls': containerId,
+ 'aria-pressed': isOpenedByThisTrigger,
+ id: thisTriggerId,
+ onClick: handleClick,
+ },
+ elementProps,
+ getButtonProps,
+ ],
+ stateAttributesMapping: fullscreenStateMapping,
+ });
+});
+
+export interface FullscreenTriggerState extends FullscreenRootState {}
+
+export interface FullscreenTriggerProps
+ extends NativeButtonProps, BaseUIComponentProps<'button', FullscreenTriggerState> {
+ /**
+ * A handle to associate the trigger with a fullscreen root rendered
+ * elsewhere in the tree. Create one with `Fullscreen.createHandle()`.
+ */
+ handle?: FullscreenHandle | undefined;
+ /**
+ * ID of the trigger. Forwarded to the rendered element and used internally
+ * to identify which trigger activated the fullscreen.
+ */
+ id?: string | undefined;
+}
+
+export namespace FullscreenTrigger {
+ export type State = FullscreenTriggerState;
+ export type Props = FullscreenTriggerProps;
+}
diff --git a/packages/react/src/fullscreen/trigger/FullscreenTriggerDataAttributes.ts b/packages/react/src/fullscreen/trigger/FullscreenTriggerDataAttributes.ts
new file mode 100644
index 00000000000..5688b049fc1
--- /dev/null
+++ b/packages/react/src/fullscreen/trigger/FullscreenTriggerDataAttributes.ts
@@ -0,0 +1,10 @@
+export enum FullscreenTriggerDataAttributes {
+ /**
+ * Present when the container is currently displayed in fullscreen.
+ */
+ fullscreen = 'data-fullscreen',
+ /**
+ * Present when the container is not currently displayed in fullscreen.
+ */
+ notFullscreen = 'data-not-fullscreen',
+}
diff --git a/packages/react/src/fullscreen/trigger/useFullscreenTriggerRegistration.ts b/packages/react/src/fullscreen/trigger/useFullscreenTriggerRegistration.ts
new file mode 100644
index 00000000000..953f0ad8d14
--- /dev/null
+++ b/packages/react/src/fullscreen/trigger/useFullscreenTriggerRegistration.ts
@@ -0,0 +1,46 @@
+'use client';
+import * as React from 'react';
+import type { FullscreenStore } from '../store/FullscreenStore';
+
+/**
+ * Returns a callback ref that registers the trigger element in the store's
+ * `triggerElements` map, so detached triggers can be looked up by id (used by
+ * `FullscreenHandle.open(triggerId)` and the active-trigger selectors).
+ *
+ * Mirrors `useTriggerRegistration` from `popupStoreUtils`, but scoped to
+ * `FullscreenStore` to avoid coupling with the popup-shaped state.
+ */
+export function useFullscreenTriggerRegistration(id: string | undefined, store: FullscreenStore) {
+ // Keep track of the currently registered element so we can unregister it on
+ // unmount or when the id changes.
+ const registeredElementIdRef = React.useRef(null);
+ const registeredElementRef = React.useRef(null);
+
+ return React.useCallback(
+ (element: Element | null) => {
+ if (id === undefined) {
+ return;
+ }
+
+ if (registeredElementIdRef.current !== null) {
+ const registeredId = registeredElementIdRef.current;
+ const registeredElement = registeredElementRef.current;
+ const currentElement = store.context.triggerElements.getById(registeredId);
+
+ if (registeredElement && currentElement === registeredElement) {
+ store.context.triggerElements.delete(registeredId);
+ }
+
+ registeredElementIdRef.current = null;
+ registeredElementRef.current = null;
+ }
+
+ if (element !== null) {
+ registeredElementIdRef.current = id;
+ registeredElementRef.current = element;
+ store.context.triggerElements.add(id, element);
+ }
+ },
+ [store, id],
+ );
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index c2c9c5d56de..285494bba10 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -15,6 +15,7 @@ export * from './drawer';
export * from './field';
export * from './fieldset';
export * from './form';
+export * from './fullscreen';
export * from './input';
export * from './menu';
export * from './menubar';