"
+ }
+ ],
+ "events": [],
+ "typedefs": [],
+ "generics": null,
+ "rest_props": {
+ "type": "Element",
+ "name": "svelte:element"
+ },
+ "contexts": []
+ },
{
"moduleName": "ProgressBar",
"filePath": "src/ProgressBar/ProgressBar.svelte",
diff --git a/docs/src/pages/components/ComposedModal.svx b/docs/src/pages/components/ComposedModal.svx
index 188b0035d0..a0149cbe96 100644
--- a/docs/src/pages/components/ComposedModal.svx
+++ b/docs/src/pages/components/ComposedModal.svx
@@ -18,6 +18,12 @@ Create a modal with a header, body, and footer. Each section can be customized i
+## With Portal
+
+Wrap `ComposedModal` in a `Portal` to ensure it renders above all z-index stacking contexts and parent overflow constraints, preventing visual clipping and layering issues.
+
+
+
## Prevent default close behavior
The modal dispatches a cancelable `close` event, allowing you to prevent the modal from closing using `e.preventDefault()`. The event includes a `trigger` property indicating what triggered the close attempt: `"escape-key"`, `"outside-click"`, or `"close-button"`.
diff --git a/docs/src/pages/components/Modal.svx b/docs/src/pages/components/Modal.svx
index 281e3001a3..fc247f7190 100644
--- a/docs/src/pages/components/Modal.svx
+++ b/docs/src/pages/components/Modal.svx
@@ -16,6 +16,12 @@ Create a basic modal dialog with primary and secondary actions. This variant is
+## With Portal
+
+Wrap `Modal` in a `Portal` to escape parent containers with `overflow: hidden` or z-index stacking contexts. This ensures the modal appears above all content and isn't clipped by parent boundaries.
+
+
+
## Custom focus
Control which element receives focus when the modal opens. Use `selectorPrimaryFocus` to specify the target element using CSS selectors.
diff --git a/docs/src/pages/components/Portal.svx b/docs/src/pages/components/Portal.svx
new file mode 100644
index 0000000000..ce808af29b
--- /dev/null
+++ b/docs/src/pages/components/Portal.svx
@@ -0,0 +1,30 @@
+
+
+The `Portal` component renders its content directly into `document.body`, allowing you to escape parent overflow constraints and z-index stacking contexts.
+
+## Default Portal
+
+Render content in a portal. This is useful for modals, tooltips, and menus that need to escape parent containers.
+
+
+
+## Multiple Portals
+
+Each portal instance is independent and creates its own element in `document.body`. Each portal is automatically cleaned up when it is removed.
+
+
+
+## Custom Tag
+
+Use the `tag` prop to specify a custom HTML element. By default, Portal uses a `div` element.
+
+
+
+## Modal with Portal
+
+Wrap `Modal` in a `Portal` to escape parent containers with `overflow: hidden` or z-index stacking contexts. This ensures the modal appears above all content and isn't clipped by parent boundaries.
+
+
diff --git a/docs/src/pages/framed/Portal/BasicPortal.svelte b/docs/src/pages/framed/Portal/BasicPortal.svelte
new file mode 100644
index 0000000000..53ba22462f
--- /dev/null
+++ b/docs/src/pages/framed/Portal/BasicPortal.svelte
@@ -0,0 +1,9 @@
+
+
+
+
This is rendered inside the div
+
+
This is rendered outside of the div
+
diff --git a/docs/src/pages/framed/Portal/ComposedModalPortal.svelte b/docs/src/pages/framed/Portal/ComposedModalPortal.svelte
new file mode 100644
index 0000000000..be3978fcc0
--- /dev/null
+++ b/docs/src/pages/framed/Portal/ComposedModalPortal.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+ This container hides overflowing content. Without a portal, the modal would
+ be clipped.
+
+
+
+
+
+
+
+
+
+ This composed modal is rendered in a portal, ensuring it appears above
+ all z-index stacking contexts and parent overflow constraints.
+
+
+ (open = false)}
+ />
+
+
+
diff --git a/docs/src/pages/framed/Portal/CustomTagPortal.svelte b/docs/src/pages/framed/Portal/CustomTagPortal.svelte
new file mode 100644
index 0000000000..427ca95d6a
--- /dev/null
+++ b/docs/src/pages/framed/Portal/CustomTagPortal.svelte
@@ -0,0 +1,7 @@
+
+
+
+ This portal uses a section tag.
+
diff --git a/docs/src/pages/framed/Portal/ModalPortal.svelte b/docs/src/pages/framed/Portal/ModalPortal.svelte
new file mode 100644
index 0000000000..6000b6d4ee
--- /dev/null
+++ b/docs/src/pages/framed/Portal/ModalPortal.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+ This container hides overflowing content. Without a portal, the modal would
+ be clipped.
+
+
+
+
+
+ (open = false)}
+ >
+
+ This modal is rendered in a portal, escaping the parent container's
+ overflow constraints and ensuring it appears above all other content.
+
+
+
+
diff --git a/docs/src/pages/framed/Portal/MultiplePortals.svelte b/docs/src/pages/framed/Portal/MultiplePortals.svelte
new file mode 100644
index 0000000000..9a1142782b
--- /dev/null
+++ b/docs/src/pages/framed/Portal/MultiplePortals.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+{#if showPortal1}
+
+ Portal content 1
+
+{/if}
+
+{#if showPortal2}
+
+ Portal content 2
+
+{/if}
diff --git a/src/Portal/Portal.svelte b/src/Portal/Portal.svelte
new file mode 100644
index 0000000000..9fe9470d38
--- /dev/null
+++ b/src/Portal/Portal.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/src/Portal/index.js b/src/Portal/index.js
new file mode 100644
index 0000000000..b15448b4f5
--- /dev/null
+++ b/src/Portal/index.js
@@ -0,0 +1 @@
+export { default as Portal } from "./Portal.svelte";
diff --git a/src/index.js b/src/index.js
index 16bd663455..f1758cb788 100644
--- a/src/index.js
+++ b/src/index.js
@@ -92,6 +92,7 @@ export { default as Pagination } from "./Pagination/Pagination.svelte";
export { default as PaginationSkeleton } from "./Pagination/PaginationSkeleton.svelte";
export { default as PaginationNav } from "./PaginationNav/PaginationNav.svelte";
export { default as Popover } from "./Popover/Popover.svelte";
+export { default as Portal } from "./Portal/Portal.svelte";
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte";
export { default as ProgressIndicator } from "./ProgressIndicator/ProgressIndicator.svelte";
export { default as ProgressIndicatorSkeleton } from "./ProgressIndicator/ProgressIndicatorSkeleton.svelte";
diff --git a/tests/Portal/Portal.multiple.test.svelte b/tests/Portal/Portal.multiple.test.svelte
new file mode 100644
index 0000000000..c5297ca82c
--- /dev/null
+++ b/tests/Portal/Portal.multiple.test.svelte
@@ -0,0 +1,16 @@
+
+
+
+ Portal content 1
+
+
+
+ Portal content 2
+
+
+
+ Portal content 3
+
+
diff --git a/tests/Portal/Portal.test.svelte b/tests/Portal/Portal.test.svelte
new file mode 100644
index 0000000000..c75204c699
--- /dev/null
+++ b/tests/Portal/Portal.test.svelte
@@ -0,0 +1,14 @@
+
+
+{#if showPortal}
+
+ {portalContent}
+
+{/if}
+
diff --git a/tests/Portal/Portal.test.ts b/tests/Portal/Portal.test.ts
new file mode 100644
index 0000000000..752692e1f7
--- /dev/null
+++ b/tests/Portal/Portal.test.ts
@@ -0,0 +1,187 @@
+import { render, screen } from "@testing-library/svelte";
+import { tick } from "svelte";
+import PortalMultipleTest from "./Portal.multiple.test.svelte";
+import PortalTest from "./Portal.test.svelte";
+
+describe("Portal", () => {
+ afterEach(() => {
+ const existingPortals = document.querySelectorAll("[data-portal]");
+ for (const portal of existingPortals) {
+ portal.remove();
+ }
+ });
+
+ it("renders portal content", async () => {
+ render(PortalTest);
+
+ const portalContent = await screen.findByText("Portal content");
+ expect(portalContent).toBeInTheDocument();
+
+ const portalElement = portalContent.closest("[data-portal]");
+ assert(portalElement instanceof HTMLElement);
+ expect(portalElement.parentElement).toBe(document.body);
+ expect(portalElement.tagName).toBe("DIV");
+ });
+
+ it("multiple portals each have their own instance", async () => {
+ render(PortalMultipleTest);
+
+ const portalContent1 = await screen.findByText("Portal content 1");
+ const portalContent2 = await screen.findByText("Portal content 2");
+ const portalContent3 = await screen.findByText("Portal content 3");
+
+ expect(portalContent1).toBeInTheDocument();
+ expect(portalContent2).toBeInTheDocument();
+ expect(portalContent3).toBeInTheDocument();
+
+ const portalElement1 = portalContent1.closest("[data-portal]");
+ const portalElement2 = portalContent2.closest("[data-portal]");
+ const portalElement3 = portalContent3.closest("[data-portal]");
+
+ expect(portalElement1).not.toBe(portalElement2);
+ expect(portalElement2).not.toBe(portalElement3);
+ expect(portalElement1).not.toBe(portalElement3);
+ expect(portalElement1).toBeInTheDocument();
+ expect(portalElement2).toBeInTheDocument();
+ expect(portalElement3).toBeInTheDocument();
+ });
+
+ it("removes portal element when instance is unmounted", async () => {
+ const { unmount } = render(PortalTest);
+
+ const portalContent = await screen.findByText("Portal content");
+ const portalElement = portalContent.closest("[data-portal]");
+ assert(portalElement instanceof HTMLElement);
+
+ unmount();
+
+ const remainingPortal = document.querySelector("[data-portal]");
+ expect(remainingPortal).not.toBeInTheDocument();
+ });
+
+ it("each portal instance is independent", async () => {
+ const { unmount: unmount1 } = render(PortalTest, {
+ props: { portalContent: "Portal 1" },
+ });
+
+ const portalContent1 = await screen.findByText("Portal 1");
+ const portalElement1 = portalContent1.closest("[data-portal]");
+ assert(portalElement1 instanceof HTMLElement);
+
+ const { unmount: unmount2 } = render(PortalTest, {
+ props: { portalContent: "Portal 2" },
+ });
+
+ const portalContent2 = await screen.findByText("Portal 2");
+ const portalElement2 = portalContent2.closest("[data-portal]");
+ assert(portalElement2 instanceof HTMLElement);
+
+ expect(portalElement1).not.toBe(portalElement2);
+
+ unmount1();
+
+ const remainingPortals = document.querySelectorAll("[data-portal]");
+ expect(remainingPortals).toHaveLength(1);
+ expect(await screen.findByText("Portal 2")).toBeInTheDocument();
+
+ unmount2();
+
+ const finalPortals = document.querySelectorAll("[data-portal]");
+ expect(finalPortals).toHaveLength(0);
+ });
+
+ it("renders slot content correctly", async () => {
+ render(PortalTest, {
+ props: { portalContent: "Custom portal content" },
+ });
+
+ const portalContent = await screen.findByText("Custom portal content");
+ expect(portalContent).toBeInTheDocument();
+ });
+
+ it("handles conditional rendering", async () => {
+ const { component } = render(PortalTest, {
+ props: { showPortal: false },
+ });
+
+ let portalContent = screen.queryByText("Portal content");
+ expect(portalContent).not.toBeInTheDocument();
+
+ let portalElement = document.querySelector("[data-portal]");
+ expect(portalElement).not.toBeInTheDocument();
+
+ component.$set({ showPortal: true });
+
+ portalContent = await screen.findByText("Portal content");
+ expect(portalContent).toBeInTheDocument();
+
+ portalElement = portalContent.closest("[data-portal]");
+ expect(portalElement).toBeInTheDocument();
+
+ component.$set({ showPortal: false });
+ await tick();
+
+ portalContent = screen.queryByText("Portal content");
+ expect(portalContent).not.toBeInTheDocument();
+
+ portalElement = document.querySelector("[data-portal]");
+ expect(portalElement).not.toBeInTheDocument();
+ });
+
+ it("uses custom tag when tag prop is specified", async () => {
+ render(PortalTest, {
+ props: { tag: "section" },
+ });
+
+ const portalContent = await screen.findByText("Portal content");
+ const portalElement = portalContent.closest("[data-portal]");
+ assert(portalElement instanceof HTMLElement);
+
+ expect(portalElement.tagName).toBe("SECTION");
+ });
+
+ it("supports different custom tags", async () => {
+ const { unmount: unmount1 } = render(PortalTest, {
+ props: { portalContent: "Article portal", tag: "article" },
+ });
+
+ const portalContent1 = await screen.findByText("Article portal");
+ const portalElement1 = portalContent1.closest("[data-portal]");
+ assert(portalElement1 instanceof HTMLElement);
+ expect(portalElement1.tagName).toBe("ARTICLE");
+
+ const { unmount: unmount2 } = render(PortalTest, {
+ props: { portalContent: "Section portal", tag: "section" },
+ });
+
+ const portalContent2 = await screen.findByText("Section portal");
+ const portalElement2 = portalContent2.closest("[data-portal]");
+ assert(portalElement2 instanceof HTMLElement);
+ expect(portalElement2.tagName).toBe("SECTION");
+
+ unmount1();
+ unmount2();
+ });
+
+ it("forwards rest props to the portal element", async () => {
+ render(PortalTest, {
+ props: {
+ class: "custom-portal-class",
+ id: "test-portal-id",
+ "data-testid": "portal-test",
+ "aria-label": "Test portal",
+ style: "background-color: red;",
+ },
+ });
+
+ const portalContent = await screen.findByText("Portal content");
+ const portalElement = portalContent.closest("[data-portal]");
+ assert(portalElement instanceof HTMLElement);
+
+ expect(portalElement).toHaveClass("custom-portal-class");
+ expect(portalElement).toHaveAttribute("id", "test-portal-id");
+ expect(portalElement).toHaveAttribute("data-testid", "portal-test");
+ expect(portalElement).toHaveAttribute("aria-label", "Test portal");
+ expect(portalElement.getAttribute("style")).toContain("background-color");
+ });
+});
diff --git a/types/Portal/Portal.svelte.d.ts b/types/Portal/Portal.svelte.d.ts
new file mode 100644
index 0000000000..64f783d888
--- /dev/null
+++ b/types/Portal/Portal.svelte.d.ts
@@ -0,0 +1,22 @@
+import type { SvelteComponentTyped } from "svelte";
+import type { SvelteHTMLElements } from "svelte/elements";
+
+type $RestProps = SvelteHTMLElements["svelte:element"];
+
+type $Props = {
+ /**
+ * Specify the tag name.
+ * @default "div"
+ */
+ tag?: keyof HTMLElementTagNameMap;
+
+ [key: `data-${string}`]: any;
+};
+
+export type PortalProps = Omit<$RestProps, keyof $Props> & $Props;
+
+export default class Portal extends SvelteComponentTyped<
+ PortalProps,
+ Record,
+ { default: Record }
+> {}
diff --git a/types/index.d.ts b/types/index.d.ts
index 16bd663455..f1758cb788 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -92,6 +92,7 @@ export { default as Pagination } from "./Pagination/Pagination.svelte";
export { default as PaginationSkeleton } from "./Pagination/PaginationSkeleton.svelte";
export { default as PaginationNav } from "./PaginationNav/PaginationNav.svelte";
export { default as Popover } from "./Popover/Popover.svelte";
+export { default as Portal } from "./Portal/Portal.svelte";
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte";
export { default as ProgressIndicator } from "./ProgressIndicator/ProgressIndicator.svelte";
export { default as ProgressIndicatorSkeleton } from "./ProgressIndicator/ProgressIndicatorSkeleton.svelte";