diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml
new file mode 100644
index 000000000..9bf501929
--- /dev/null
+++ b/.github/workflows/pkg-pr-new.yml
@@ -0,0 +1,29 @@
+name: Publish Preview Package
+
+on: [push, pull_request]
+
+jobs:
+ build-and-publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Enable Corepack
+ run: corepack enable
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: "pnpm"
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build package
+ run: pnpm run build
+
+ - name: Publish preview packages
+ run: pnpm dlx pkg-pr-new publish './libs/*'
\ No newline at end of file
diff --git a/apps/docs/src/routes/base/modal/examples/basic.tsx b/apps/docs/src/routes/base/modal/examples/basic.tsx
new file mode 100644
index 000000000..9a848ff19
--- /dev/null
+++ b/apps/docs/src/routes/base/modal/examples/basic.tsx
@@ -0,0 +1,11 @@
+import { Modal } from "@kunai-consulting/qwik";
+import { component$ } from "@qwik.dev/core";
+
+export default component$(() => {
+ return (
+
+ Open Modal
+ Some content
+
+ );
+});
diff --git a/apps/docs/src/routes/base/modal/examples/nested.tsx b/apps/docs/src/routes/base/modal/examples/nested.tsx
new file mode 100644
index 000000000..5cdb30393
--- /dev/null
+++ b/apps/docs/src/routes/base/modal/examples/nested.tsx
@@ -0,0 +1,28 @@
+import { Modal } from "@kunai-consulting/qwik";
+import { component$, useSignal } from "@qwik.dev/core";
+
+export default component$(() => {
+ const nestedOpen = useSignal(false);
+
+ return (
+
+ Open Modal
+
+ First Modal
+ This is the first modal.
+
+ {/* Nested Modal */}
+
+ Nested Modal Trigger
+
+ Nested Modal Title
+ Nested Modal Content
+ Close Nested
+
+
+
+ Close
+
+
+ );
+});
diff --git a/apps/docs/src/routes/base/modal/index.mdx b/apps/docs/src/routes/base/modal/index.mdx
new file mode 100644
index 000000000..1016b0720
--- /dev/null
+++ b/apps/docs/src/routes/base/modal/index.mdx
@@ -0,0 +1,8 @@
+import Basic from "./examples/basic";
+import Nested from "./examples/nested";
+
+# Modal
+
+
+
+
\ No newline at end of file
diff --git a/libs/components/package.json b/libs/components/package.json
index 47a32a896..f1823e127 100644
--- a/libs/components/package.json
+++ b/libs/components/package.json
@@ -31,7 +31,8 @@
"vite": "^7",
"vite-tsconfig-paths": "^4.2.1",
"@qwik.dev/core": "2.0.0-beta.9",
- "vitest-browser-qwik": "https://pkg.pr.new/kunai-consulting/vitest-browser-qwik@949e6ac"
+ "vitest-browser-qwik": "https://pkg.pr.new/kunai-consulting/vitest-browser-qwik@949e6ac",
+ "@fluejs/noscroll": "^1.0.0"
},
"dependencies": {
"@oddbird/css-anchor-positioning": "^0.6"
diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts
index 8286c59fb..f4d4db654 100644
--- a/libs/components/src/index.ts
+++ b/libs/components/src/index.ts
@@ -17,6 +17,7 @@ export * as Tabs from "./tabs";
export * as Toggle from "./toggle";
export * as Menu from "./menu";
export * as Progress from "./progress";
+export * as Modal from "./modal";
export { Render } from "./render/render";
diff --git a/libs/components/src/modal/index.ts b/libs/components/src/modal/index.ts
new file mode 100644
index 000000000..a60c81d4b
--- /dev/null
+++ b/libs/components/src/modal/index.ts
@@ -0,0 +1,6 @@
+export { ModalRoot as Root } from "./modal-root";
+export { ModalContent as Content } from "./modal-content";
+export { ModalTitle as Title } from "./modal-title";
+export { ModalDescription as Description } from "./modal-description";
+export { ModalClose as Close } from "./modal-close";
+export { ModalTrigger as Trigger } from "./modal-trigger";
diff --git a/libs/components/src/modal/modal-close.tsx b/libs/components/src/modal/modal-close.tsx
new file mode 100644
index 000000000..6a8c3c707
--- /dev/null
+++ b/libs/components/src/modal/modal-close.tsx
@@ -0,0 +1,22 @@
+import { $, type PropsOf, Slot, component$, useContext } from "@qwik.dev/core";
+import { Render } from "../render/render";
+import { modalContextId } from "./modal-root";
+
+export const ModalClose = component$((props: PropsOf<"button">) => {
+ const context = useContext(modalContextId);
+
+ const handleClose$ = $(() => {
+ context.isOpen.value = false;
+ });
+
+ return (
+
+
+
+ );
+});
diff --git a/libs/components/src/modal/modal-content.tsx b/libs/components/src/modal/modal-content.tsx
new file mode 100644
index 000000000..290f315fb
--- /dev/null
+++ b/libs/components/src/modal/modal-content.tsx
@@ -0,0 +1,85 @@
+import { $, type PropsOf, Slot, component$, useContext, useSignal } from "@qwik.dev/core";
+import { modalContextId } from "./modal-root";
+
+export const ModalContent = component$((props: PropsOf<"dialog">) => {
+ const context = useContext(modalContextId);
+ const isDownOnBackdrop = useSignal(false);
+ const descriptionId = `${context.localId}-description`;
+ const titleId = `${context.localId}-title`;
+
+ /**
+ * Determines if the backdrop of the Modal has been clicked.
+ */
+ const isBackdropEvent$ = $(
+ (dialogEl: HTMLDialogElement | undefined, event: PointerEvent): boolean => {
+ if (!dialogEl) return false;
+ if (event.pointerId === -1) return false;
+
+ const modal = dialogEl.getBoundingClientRect();
+
+ const { clientX: x, clientY: y } = event;
+
+ const isInsideModal =
+ x >= modal.left && x <= modal.right && y >= modal.top && y <= modal.bottom;
+
+ if (isInsideModal) {
+ return false;
+ }
+
+ return true;
+ }
+ );
+
+ const handleBackdropDown$ = $(async (e: PointerEvent) => {
+ e.stopPropagation();
+ if (!context.contentRef.value) {
+ isDownOnBackdrop.value = false;
+ return;
+ }
+ isDownOnBackdrop.value = await isBackdropEvent$(context.contentRef.value, e);
+ });
+
+ const handleBackdropSlide$ = $(async (e: PointerEvent) => {
+ e.stopPropagation();
+ if (!isDownOnBackdrop.value) {
+ isDownOnBackdrop.value = false;
+ return;
+ }
+
+ if (!context.closeOnOutsideClick) {
+ isDownOnBackdrop.value = false;
+ return;
+ }
+
+ if (!context.contentRef.value) {
+ isDownOnBackdrop.value = false;
+ return;
+ }
+
+ const isBackdrop = await isBackdropEvent$(context.contentRef.value, e);
+
+ if (isBackdrop) {
+ context.isOpen.value = false;
+ }
+
+ isDownOnBackdrop.value = false;
+ });
+
+ const handleClose$ = $(() => {
+ context.isOpen.value = false;
+ });
+
+ return (
+
+ );
+});
diff --git a/libs/components/src/modal/modal-description.tsx b/libs/components/src/modal/modal-description.tsx
new file mode 100644
index 000000000..92c44c05b
--- /dev/null
+++ b/libs/components/src/modal/modal-description.tsx
@@ -0,0 +1,18 @@
+import { type PropsOf, Slot, component$, useConstant, useContext } from "@qwik.dev/core";
+import { Render } from "../render/render";
+import { modalContextId } from "./modal-root";
+
+export const ModalDescription = component$((props: PropsOf<"div">) => {
+ const context = useContext(modalContextId);
+ const descriptionId = `${context.localId}-description`;
+
+ useConstant(() => {
+ context.isDescription.value = true;
+ });
+
+ return (
+
+
+
+ );
+});
diff --git a/libs/components/src/modal/modal-root.tsx b/libs/components/src/modal/modal-root.tsx
new file mode 100644
index 000000000..156fce2eb
--- /dev/null
+++ b/libs/components/src/modal/modal-root.tsx
@@ -0,0 +1,107 @@
+import { createNoScroll, markScrollable } from "@fluejs/noscroll";
+import { initTouchHandler, resetTouchHandler } from "@fluejs/noscroll/touch";
+import { type BindableProps, useBindings } from "@kunai-consulting/qwik-utils";
+import {
+ type PropsOf,
+ type Signal,
+ Slot,
+ component$,
+ createContextId,
+ noSerialize,
+ useConstant,
+ useContext,
+ useContextProvider,
+ useId,
+ useSignal,
+ useTask$
+} from "@qwik.dev/core";
+import { Render } from "../render/render";
+
+export const modalContextId = createContextId("qds-modal");
+
+type ModalContext = {
+ contentRef: Signal;
+ isOpen: Signal;
+ closeOnOutsideClick: boolean;
+ level: number;
+ isTitle: Signal;
+ isDescription: Signal;
+ localId: string;
+};
+
+type ModalRootProps = PropsOf<"div"> &
+ BindableProps<{
+ open: boolean;
+ }> & {
+ closeOnOutsideClick?: boolean;
+ };
+
+export const ModalRoot = component$((props: ModalRootProps) => {
+ const contentRef = useSignal();
+ const isInitialized = useSignal(false);
+ const disablePageScrollFn = useSignal<() => void>();
+ const enablePageScrollFn = useSignal<() => void>();
+ const isTitle = useSignal(false);
+ const isDescription = useSignal(false);
+ const localId = useId();
+
+ // handling nested state
+ const parentContext = useContext(modalContextId, null);
+ const level = useConstant(() => {
+ return (parentContext?.level ?? 0) + 1;
+ });
+
+ const { closeOnOutsideClick = true, ...restProps } = props;
+
+ const { openSig: isOpen } = useBindings(restProps, {
+ open: false
+ });
+
+ useTask$(({ track, cleanup }) => {
+ track(isOpen);
+
+ if (!isInitialized.value) {
+ if (!contentRef.value) return;
+ markScrollable(contentRef.value);
+ isInitialized.value = true;
+
+ const { disablePageScroll, enablePageScroll } = createNoScroll({
+ onInitScrollDisable: initTouchHandler,
+ onResetScrollDisable: resetTouchHandler
+ });
+
+ disablePageScrollFn.value = noSerialize(disablePageScroll);
+ enablePageScrollFn.value = noSerialize(enablePageScroll);
+ }
+
+ if (isOpen.value) {
+ contentRef.value?.showModal();
+ disablePageScrollFn.value?.();
+ } else {
+ contentRef.value?.close();
+ }
+
+ cleanup(() => {
+ if (level > 1) return;
+ enablePageScrollFn.value?.();
+ });
+ });
+
+ const context: ModalContext = {
+ contentRef,
+ isOpen,
+ closeOnOutsideClick,
+ level,
+ isTitle,
+ isDescription,
+ localId
+ };
+
+ useContextProvider(modalContextId, context);
+
+ return (
+
+
+
+ );
+});
diff --git a/libs/components/src/modal/modal-title.tsx b/libs/components/src/modal/modal-title.tsx
new file mode 100644
index 000000000..c5cc0a4ff
--- /dev/null
+++ b/libs/components/src/modal/modal-title.tsx
@@ -0,0 +1,18 @@
+import { type PropsOf, Slot, component$, useConstant, useContext } from "@qwik.dev/core";
+import { Render } from "../render/render";
+import { modalContextId } from "./modal-root";
+
+export const ModalTitle = component$((props: PropsOf<"div">) => {
+ const context = useContext(modalContextId);
+ const titleId = `${context.localId}-title`;
+
+ useConstant(() => {
+ context.isTitle.value = true;
+ });
+
+ return (
+
+
+
+ );
+});
diff --git a/libs/components/src/modal/modal-trigger.tsx b/libs/components/src/modal/modal-trigger.tsx
new file mode 100644
index 000000000..bdeeca938
--- /dev/null
+++ b/libs/components/src/modal/modal-trigger.tsx
@@ -0,0 +1,17 @@
+import { $, type PropsOf, Slot, component$, useContext } from "@qwik.dev/core";
+import { Render } from "../render/render";
+import { modalContextId } from "./modal-root";
+
+export const ModalTrigger = component$((props: PropsOf<"button">) => {
+ const context = useContext(modalContextId);
+
+ const handleToggle$ = $(() => {
+ context.isOpen.value = !context.isOpen.value;
+ });
+
+ return (
+
+
+
+ );
+});
diff --git a/libs/components/src/modal/modal.browser.tsx b/libs/components/src/modal/modal.browser.tsx
new file mode 100644
index 000000000..3607cabfd
--- /dev/null
+++ b/libs/components/src/modal/modal.browser.tsx
@@ -0,0 +1,319 @@
+import { type PropsOf, component$, useSignal } from "@qwik.dev/core";
+import { page, userEvent } from "@vitest/browser/context";
+import { expect, test } from "vitest";
+import { render } from "vitest-browser-qwik";
+import { Modal } from "..";
+import { pointer } from "../../vitest/pointer";
+
+pointer.showDebugDots = true;
+
+// Top-level locator constants using data-testid
+const Root = page.getByTestId("root");
+const Trigger = page.getByTestId("trigger");
+const Content = page.getByTestId("content"); // This is the