);
diff --git a/src/components/ui/modal.tsx b/src/components/ui/modal.tsx
new file mode 100644
index 0000000..b83af70
--- /dev/null
+++ b/src/components/ui/modal.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom";
+
+interface Props {
+ open: boolean;
+ /** Called when user clicks the backdrop or presses Escape. */
+ onClose: () => void;
+ children: React.ReactNode;
+ /**
+ * ARIA label fallback when no labelled element exists inside.
+ * Optional; if you have a heading inside the modal with id, set
+ * `aria-labelledby` on a wrapper instead.
+ */
+ ariaLabel?: string;
+}
+
+/**
+ * Portal-based modal.
+ *
+ * Why a portal
+ * ------------
+ * `position: fixed` becomes positioned relative to a transformed
+ * ancestor's containing block, not the viewport, when ANY ancestor
+ * has a `transform`, `perspective`, or `filter` applied. Our
+ * `animate-in` utility sets `transform: translateY(0)` on a lot of
+ * dashboard/tenant-detail containers (because of the fade-up
+ * animation, which uses `animation-fill-mode: both` to keep the
+ * transform on after the animation finishes). That broke modals
+ * rendered as in-place children — they centred to the panel they
+ * lived in, not to the page.
+ *
+ * Rendering at `document.body` via `createPortal` escapes every
+ * containing-block ancestor and gives us true viewport coordinates.
+ *
+ * UX details
+ * ----------
+ * - Backdrop click triggers `onClose`. (Bubbling check: only fires
+ * when the click target IS the backdrop, not the panel inside.)
+ * - Escape key triggers `onClose`. Standard modal expectation.
+ * - `body` overflow is locked while open so background content
+ * doesn't scroll behind the modal.
+ * - Renders nothing on first paint server-side, then mounts on
+ * client. `useEffect` gating ensures `document.body` is available;
+ * without it Next.js SSR would throw on `document` reference.
+ */
+export function Modal({ open, onClose, children, ariaLabel }: Props) {
+ const closeRef = useRef(onClose);
+ closeRef.current = onClose;
+
+ useEffect(() => {
+ if (!open) return;
+
+ // Lock background scroll. Restore on unmount/close.
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") closeRef.current();
+ };
+ window.addEventListener("keydown", onKey);
+
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ window.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ if (!open) return null;
+ if (typeof document === "undefined") return null;
+
+ return createPortal(
+