diff --git a/component/ElementCall.module.css b/component/ElementCall.module.css
index 91213e422..ee792a731 100644
--- a/component/ElementCall.module.css
+++ b/component/ElementCall.module.css
@@ -34,10 +34,33 @@ component rather than a page makes. */
-webkit-tap-highlight-color: transparent;
}
-/* Compound's overlay container, which holds tooltips and popovers, has to fill
-the container for the elements inside it to be positioned against it. The
-standalone page does the same for the container under `#root`. */
-.root > [data-overlay-container] {
- position: relative;
+/* floating-ui's portal node, which `PortalRoot` puts here to hold the call
+along with the tooltips Compound floats over it. It fills the container, and
+lays out its children as the container would. */
+.root > [data-floating-ui-portal] {
+ display: flex;
+ flex-direction: column;
block-size: 100%;
+ position: relative;
+}
+
+/* The call itself, in the wrapper `PortalRoot` gives it. It is a stacking
+context of its own, as the standalone page's `#root` is, so that the layers
+the call view uses for its header, footer and overlays stay below the modals
+and toasts, which are portalled into the root and carry no layer of their own,
+and below the tooltips. */
+.content {
+ display: flex;
+ flex-direction: column;
+ block-size: 100%;
+ position: relative;
+ isolation: isolate;
+}
+
+/* The tooltips sit in portal nodes of their own, nested in ours. Raised, so
+that a tooltip opened inside a modal shows above it: the modal is portalled
+straight into the root and so comes later in the document than our node. */
+.root > [data-floating-ui-portal] > [data-floating-ui-portal] {
+ position: relative;
+ z-index: 1;
}
diff --git a/component/PortalRoot.test.tsx b/component/PortalRoot.test.tsx
new file mode 100644
index 000000000..f5d133d83
--- /dev/null
+++ b/component/PortalRoot.test.tsx
@@ -0,0 +1,43 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { render, screen } from "@testing-library/react";
+import { Tooltip, TooltipProvider } from "@vector-im/compound-web";
+import { afterEach, describe, expect, test } from "vitest";
+
+import { PortalRoot } from "./PortalRoot";
+
+describe("PortalRoot", () => {
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ afterEach(() => root.replaceChildren());
+
+ // A label tooltip is in the DOM from the start, hidden by the stylesheet until
+ // hovered, which is what makes it show as bare text when it lands outside the
+ // root
+ const tree = (
+
+
+
+
+
+ );
+
+ test("Compound's tooltips float into the root", () => {
+ render({tree});
+ expect(root).toContainElement(
+ screen.getByRole("button", { name: "Mute microphone" }),
+ );
+ expect(root).toContainElement(screen.getByText("Mute microphone"));
+ });
+
+ test("without it they float into the body", () => {
+ render(tree);
+ expect(root).not.toContainElement(screen.getByText("Mute microphone"));
+ expect(document.body).toContainElement(screen.getByText("Mute microphone"));
+ });
+});
diff --git a/component/PortalRoot.tsx b/component/PortalRoot.tsx
new file mode 100644
index 000000000..20eeecb07
--- /dev/null
+++ b/component/PortalRoot.tsx
@@ -0,0 +1,44 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { FloatingPortal } from "@floating-ui/react";
+import { type FC, type ReactNode } from "react";
+
+import styles from "./ElementCall.module.css";
+
+/**
+ * Keeps what Compound floats over Element Call, its tooltips, inside Element
+ * Call's root.
+ *
+ * Compound's `Tooltip` renders its bubble through floating-ui's
+ * `FloatingPortal`, which appends to `document.body` unless it sits inside
+ * another `FloatingPortal`, whose node it then shares. Compound offers no way
+ * to name a container, so this is that enclosing portal: it puts its node
+ * inside `root` and renders the children into it, and every tooltip they open
+ * lands in the same node, inside the element the component's stylesheet is
+ * confined to. Outside it, a tooltip matches none of the component's rules and
+ * shows as bare text, permanently, since the rule that hides a label tooltip
+ * until it is hovered is one of them.
+ *
+ * The children go in a wrapper of their own, which stands in for the `#root`
+ * of the standalone page: it fills the node and is a stacking context, so
+ * that the call's own layers (its header and footer, its overlays) stay below
+ * the modals and toasts portalled into the root, and below the tooltips beside
+ * it. `ElementCall.module.css` lays both out.
+ *
+ * Nothing renders until `root` exists, just as nothing renders before Element
+ * Call's container does. The portal's tab order guards are for a focus
+ * manager, which nothing in Element Call puts here, so they are off.
+ */
+export const PortalRoot: FC<{
+ root: HTMLElement | null;
+ children: ReactNode;
+}> = ({ root, children }) => (
+
+
{children}
+
+);
diff --git a/component/index.tsx b/component/index.tsx
index b3f04e00c..7ca7de306 100644
--- a/component/index.tsx
+++ b/component/index.tsx
@@ -59,6 +59,7 @@ import { ErrorPage } from "../src/FullScreenView";
import { ClientProvider } from "../src/ClientContext";
import { HostBridgeProvider } from "../src/HostBridge";
import { RootElementProvider, useRootElement } from "../src/RootElementContext";
+import { PortalRoot } from "./PortalRoot";
import {
configurationForIntent,
componentProperties,
@@ -321,34 +322,36 @@ export const ElementCall: FC = ({
{ready && (
-
- {/* Whatever goes wrong in here is shown in here. Left to
+
+
+ {/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
- }
- // A broken call should not hold the host on screen
- onError={() => void hostBridge.setAlwaysOnScreen(false)}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
+ }
+ // A broken call should not hold the host on screen
+ onError={() => void hostBridge.setAlwaysOnScreen(false)}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)}