diff --git a/component/CallPortalRoot.test.tsx b/component/CallPortalRoot.test.tsx
new file mode 100644
index 000000000..bfa23971b
--- /dev/null
+++ b/component/CallPortalRoot.test.tsx
@@ -0,0 +1,57 @@
+/*
+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 { expect, test } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { type ReactNode } from "react";
+import { Tooltip, TooltipProvider } from "@vector-im/compound-web";
+
+import { CallPortalRoot } from "./CallPortalRoot";
+
+// A button whose accessible name comes from its tooltip, as every icon button
+// in the call does: the name is lost whenever the tooltip has nowhere to go.
+const LabelledButton = (): ReactNode => (
+
+
+
+
+
+);
+
+test("floats into the host document with no root", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Microphone" })).toBeTruthy();
+});
+
+test("floats into the root it is given", () => {
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ render(
+
+
+ ,
+ );
+ const button = screen.getByRole("button", { name: "Microphone" });
+ const label = document.getElementById(
+ button.getAttribute("aria-labelledby")!,
+ );
+ expect(root.contains(label)).toBe(true);
+ root.remove();
+});
+
+test("waits for a root that is not available yet", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull();
+});
diff --git a/component/CallPortalRoot.tsx b/component/CallPortalRoot.tsx
new file mode 100644
index 000000000..281eb28c0
--- /dev/null
+++ b/component/CallPortalRoot.tsx
@@ -0,0 +1,26 @@
+/*
+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 { type FC, type ReactNode } from "react";
+import { PortalRoot } from "@vector-im/compound-web";
+
+interface Props {
+ root: HTMLElement | null | undefined;
+ children: ReactNode;
+}
+
+/**
+ * Where Compound floats the call's menus and tooltips. With no `root` the call
+ * is not wrapped at all and they go into the host document's body; `null` is
+ * Compound's "not available yet", and the floating parts wait for it.
+ */
+export const CallPortalRoot: FC = ({ root, children }): ReactNode =>
+ root === undefined ? (
+ children
+ ) : (
+ {children}
+ );
diff --git a/component/api.ts b/component/api.ts
index c9e7a2fd7..fa2928eb1 100644
--- a/component/api.ts
+++ b/component/api.ts
@@ -121,4 +121,18 @@ export interface ElementCallProps {
* the most recently set language wins for all of them.
*/
language?: string;
+ /**
+ * Where the parts of Element Call that float above it — its menus and
+ * tooltips, which Compound portals out of the call — are rendered. Left out,
+ * they go into the host document's body, as they do for any other component
+ * on the host's page.
+ *
+ * A host that moves Element Call's DOM into another document, such as a
+ * Document Picture-in-Picture window, passes that document's body here so
+ * that a menu opens in the window its button is in. Changes take effect at
+ * once. `null` means the root is not available yet, as from a callback ref
+ * that has not fired: menus and tooltips wait for it rather than falling
+ * back to the body.
+ */
+ portalRoot?: HTMLElement | null;
}
diff --git a/component/index.tsx b/component/index.tsx
index 8f901c139..b3f7b6209 100644
--- a/component/index.tsx
+++ b/component/index.tsx
@@ -82,6 +82,7 @@ import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css";
import { useComponentHostBridge } from "./host";
+import { CallPortalRoot } from "./CallPortalRoot";
import { type ElementCallProps } from "./api";
import { supportedLanguages, translationsBackend } from "./localization";
@@ -154,6 +155,7 @@ export const ElementCall: FC = ({
ref,
theme,
language,
+ portalRoot,
}): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
@@ -240,34 +242,36 @@ export const ElementCall: FC = ({
// Stands in for the standalone page's `#root`: see `.content`
// in ElementCall.module.css
-
- {/* 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)}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {/* 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)}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)}
diff --git a/component/package.json b/component/package.json
index 0d45eb4d7..9929b39bb 100644
--- a/component/package.json
+++ b/component/package.json
@@ -39,7 +39,7 @@
},
"peerDependencies": {
"@vector-im/compound-design-tokens": "^11.0.0",
- "@vector-im/compound-web": "^10.0.0",
+ "@vector-im/compound-web": "^10.3.1",
"matrix-js-sdk": "*",
"react": "^19",
"react-dom": "^19"
diff --git a/package.json b/package.json
index b30f57ce3..3c7623366 100644
--- a/package.json
+++ b/package.json
@@ -84,7 +84,7 @@
"@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^11.0.0",
- "@vector-im/compound-web": "^10.0.0",
+ "@vector-im/compound-web": "^10.4.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
diff --git a/playwright/component/audio-menu.spec.ts b/playwright/component/audio-menu.spec.ts
index f4d811598..3276c28a7 100644
--- a/playwright/component/audio-menu.spec.ts
+++ b/playwright/component/audio-menu.spec.ts
@@ -129,7 +129,7 @@ async function openDeviceList(page: Page, pane: Locator): Promise {
.getByRole("button", { name: "Microphone" })
.click({ timeout: 60_000 });
await expect(page.getByRole("menu")).toBeVisible();
- const list = page.locator("[role='menu'] div[role='none']").first();
+ const list = page.getByRole("menu").getByTestId("device-list");
await expect(list).toBeVisible();
return list;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 69805cfcd..da411cd4f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -134,8 +134,8 @@ importers:
specifier: ^11.0.0
version: 11.1.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web':
- specifier: ^10.0.0
- version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: ^10.4.0
+ version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
@@ -3240,13 +3240,13 @@ packages:
react:
optional: true
- '@vector-im/compound-web@10.1.0':
- resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
+ '@vector-im/compound-web@10.4.0':
+ resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
'@types/react': '*'
- '@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0'
+ '@vector-im/compound-design-tokens': '>=1.6.1 <12.0.0'
react: ^18 || ^19.0.0
peerDependenciesMeta:
'@types/react':
@@ -8672,7 +8672,7 @@ snapshots:
'@types/react': 19.2.18
react: 19.2.8
- '@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@vector-im/compound-web@10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
diff --git a/src/Modal.test.tsx b/src/Modal.test.tsx
index 74670b6db..223bbbc8d 100644
--- a/src/Modal.test.tsx
+++ b/src/Modal.test.tsx
@@ -11,6 +11,7 @@ import { type ReactNode, useState } from "react";
import userEvent from "@testing-library/user-event";
import { Modal } from "./Modal";
+import { RootElementProvider } from "./RootElementContext";
const originalMatchMedia = window.matchMedia;
afterEach(() => {
@@ -70,3 +71,31 @@ test("the modal renders as a drawer in mobile viewports", () => {
);
expect(queryByRole("dialog")).toMatchSnapshot();
});
+
+test("the modal can be closed by clicking the backdrop, in another window too", async () => {
+ // A dialog moved into another window, as a host does for a Document
+ // Picture-in-Picture window: its nodes are not `Node`s of this window
+ const frame = document.createElement("iframe");
+ document.body.appendChild(frame);
+ const otherBody = frame.contentDocument!.body;
+
+ function ModalFn(): ReactNode {
+ const [isOpen, setOpen] = useState(true);
+ return (
+
+ setOpen(false)}>
+