Why your popup disappears in fullscreen

· 6 min read

A tester told us the right-click menu did not open while the grid was fullscreen.

It opened. The node was in the DOM, it had a real bounding box, it received events, and aria-expanded flipped on the trigger. Nothing was wrong with the menu. It simply was not visible.

Three of the five problems that tester reported came from that one cause, and fixing it uncovered a worse bug underneath. This is the mechanism, the fix, and what moving a DOM node actually costs.

z-index cannot help here

requestFullscreen() promotes the element into the top layer — a rendering layer that spans the viewport and sits above everything in the normal document flow. Two consequences follow, and both are absolute:

  • z-index does not cross the boundary. A z-index in the normal flow orders elements within the normal flow. Against the top layer it does nothing. 2147483647 loses to a fullscreen element exactly as 1 does.
  • A backdrop sits in between. Every top-layer element has a ::backdrop pseudo-element painted between it and the rest of the document, and for fullscreen the browser's own stylesheet makes that backdrop opaque black.

So the menu was never un-rendered. It was painted, correctly, underneath a black sheet no stacking value can reach over.

Fullscreen is not the only API that does this. dialog.showModal() and elements shown with popover are promoted to the same layer, and elements inside it stack among themselves in the order they were added. That detail matters later.

Ten lines that reproduce it

const stage = document.getElementById('stage');

const menu = document.createElement('div');
menu.textContent = 'I am open. You cannot see me.';
menu.style.cssText =
  'position:fixed;top:20px;left:20px;z-index:2147483647;background:#fff;padding:8px';

stage.addEventListener('click', async () => {
  await stage.requestFullscreen();
  document.body.appendChild(menu);
  console.log(menu.isConnected, menu.getBoundingClientRect().width);
  // true, and a real width. Still nothing on screen.
});

Every instrument says the element is fine, which is what makes this expensive to debug. isConnected is true. The bounding box has real numbers. Hit-testing works, so keyboard focus and click handlers behave. The only broken thing is the one thing the console cannot report.

The fix is placement, not stacking

If stacking cannot reach across the boundary, the popup has to be on the other side of it — inside whatever is fullscreen:

const parent = document.fullscreenElement ?? document.body;
parent.appendChild(menu);

Ours resolves in three steps, because a browser can refuse requestFullscreen() and we fall back to a CSS-emulated fullscreen for that case:

export function portalParent(): HTMLElement {
  const d = document as Document & { webkitFullscreenElement?: Element | null };
  const native = (d.fullscreenElement ?? d.webkitFullscreenElement ?? null) as HTMLElement | null;
  if (native) return native;
  return document.querySelector<HTMLElement>('.pivot-is-fullscreen') ?? document.body;
}

Five places mount something outside the grid: the one-at-a-time popup layer, the field chooser, two hosts belonging to the import dialog, and the unlicensed badge. Every one of them appended to document.body, so every one of them was invisible in fullscreen.

Worth admitting: our React renderer already knew this. It flips its portal target to the grid container when the grid goes fullscreen, and the plan for the DOM renderer said, in writing, to re-parent open popups. It had not been written. A note in a plan is not a fix.

Popups that outlive the toggle

Placing a popup correctly when it opens is only half of it. A user can toggle fullscreen while a dialog is already open, and a dialog that was placed correctly a second ago is now on the wrong side of the boundary:

document.addEventListener('fullscreenchange', () => {
  const parent = document.fullscreenElement ?? document.body;
  if (menu.parentElement !== parent) parent.appendChild(menu);
});

Transient popups only need the placement rule. Long-lived surfaces — a field chooser someone left open, a status badge — need the listener, and they need it in both directions, because leaving fullscreen strands them just as reliably.

The bug the fix uncovered

With placement fixed, right-clicking in fullscreen produced a menu. It also produced a white sheet over the entire grid.

Our popup host copies the grid root's inline style so it inherits the theme's CSS custom properties. That is a reasonable thing to do and it had worked for months. What the CSS-emulated fullscreen writes onto that same root is:

position: fixed;
inset: 0;
z-index: 9990;

So the host copied the theme variables and a full-viewport layout, and because it also carries the root's class it painted the root's background too. Every popup became an opaque page-sized panel with a small menu in the corner of it. The menu was visible. Everything else was gone.

The lesson is one line long: copying style wholesale copies layout, not just variables. The host now removes position, inset, z-index, width and height after the copy. The variables stay; the root's layout stays the root's business.

This bug existed before the first fix and could not be seen — the popup had to become visible before anyone could notice that it was covering the page.

What moving a node costs

Re-parenting is not free. Moving an element re-runs its lifecycle: an <iframe> inside it reloads, CSS transitions and animations restart, and a focused descendant can lose focus. A <video> keeps playing, but a freshly-created one restarts its load.

For a menu that opened a moment ago, none of that matters. For a surface with state of its own, check what you are moving before adopting this pattern everywhere.

The road we did not take

There is a second way out, and it needs no moving at all. Because dialog.showModal() and popover promote elements into the same top layer, and because top-layer elements stack in the order they were added, a modal dialog opened after an element went fullscreen paints above it.

We did not take it. Our popups also have to work in the CSS-emulated fallback, where no top layer is involved at all, and one placement rule that covers both paths was simpler than maintaining two mechanisms with different failure modes. That is our constraint, not a recommendation — for an application that only ever needs the native API, the top layer is the smaller answer.

Testing without a Fullscreen API

jsdom does not implement the Fullscreen API, so there is nothing to drive and nothing to observe. Chasing that with a real browser for one placement rule would have been a lot of machinery for a small claim.

Instead we stub document.fullscreenElement and assert where the node lands:

it('mounts inside the fullscreen element, so the popup is painted', () => {
  fullscreen = host;                       // what the stubbed getter returns
  const portal = createPortalLayer(host);
  const root = portal.openPopup(() => {});
  expect(host.contains(root)).toBe(true);
});

The placement is the claim. If the popup is inside the fullscreen element, the browser paints it; if it is a sibling on <body>, it does not. Seventeen tests came with this fix, and reverting each change in turn made the matching test fail — which is the only way to know a test was testing anything.


This shipped in the 2026-09-06 release. The fullscreen control itself is described in Features.