Modal new
import { MAX_OPEN_DIALOGS, modal } from "@katanakit/ui";
modal.open({
title: "Delete project?",
message: "This cannot be undone.",
footer: actions,
});Built on the native <dialog> element, so it runs in the browser's top layer with a real backdrop. On top of that the component adds the two things the native behaviour leaves open: an explicit Tab cycle, and a guard that pulls focus back when it escapes.
No JSX, no framework runtime — the same call works from Astro, Nuxt or a plain script.
The portal decision
The component owns the portal: it appends to document.body and removes itself on close. Leaving the portal to the consumer keeps the factory "pure" at the cost of making every call site wire up the portal, the focus restoration and the scroll lock — and getting that wiring wrong does not throw, it fails as inaccessible. Focus lands nowhere and the keyboard user is stranded.
Pass container to send it somewhere else — a shadow root, or a subtree when you need the dialog scoped:
const host = document.querySelector("#drawer-host");
modal.open({ title: "Rename", container: host });Focus
| Behaviour | What happens |
|---|---|
| On open | Focus moves to initialFocus, else the first focusable child, else the dialog |
| Tab / Shift+Tab | Cycles inside the dialog and wraps at both ends |
| Focus escapes | A document-level focusin guard pulls it back |
| On close | Focus returns to whatever was focused before open() |
| Nested dialogs | Closing the inner one restores focus to the outer dialog, not to the detached inner target |
That last row is the reason the restore logic checks the open-dialog stack before the saved target: a nested dialog's opener often lives inside the dialog that is itself closing, and focusing it would trap focus in a detached subtree.
returnFocus: false opts out, for flows that deliberately move focus elsewhere — for example, navigating to a confirmation screen after the dialog closes.
The close button is a real control
It stays in the Tab order and keeps its accessible name. That matters when dismissOnEscape: false disables both Escape and the backdrop: the button is then the only way out, and a keyboard-only user would otherwise be stranded. The dialog is always labelled by its heading, never by that button.
Scroll lock
Opening a dialog sets body { overflow: hidden }; closing restores whatever the value was before. The counter is refcounted, so closing one dialog while another is still open does not release the page — the failure mode would be a scrollable page behind an open modal.
Options
| Option | Type | Default | Notes |
|---|---|---|---|
title | string | — | Rendered as <h2> and wired via aria-labelledby. |
ariaLabel | string | "Dialog" | Accessible name. Set it when there is no title. |
message | string | — | Short body. Ignored when content is set. |
content | string | Node | — | Arbitrary body. Strings go through textContent, never HTML. |
footer | string | Node | — | Footer row. Same insertion rules as content. |
size | "sm" | "md" | "lg" | "full" | "md" | Validated at runtime, not trusted. |
dismissible | boolean | true | Renders the close button. |
dismissOnEscape | boolean | true | Escape and backdrop clicks. |
closeLabel | string | "Close dialog" | Accessible name for the close button. |
container | HTMLElement | null | document.body | Portal target. Must be in the document. |
initialFocus | HTMLElement | null | first focusable | Where focus lands on open. Must be inside the dialog. |
returnFocus | boolean | true | Restore focus to the opener on close. |
onClose | (id: string) => void | — | Called once the dialog leaves the DOM. |
A role="dialog" with neither aria-labelledby nor aria-label is announced as nothing when it opens, so a dialog with no title falls back to ariaLabel.
Limits
At most MAX_OPEN_DIALOGS dialogs are open at once. Opening a sixth evicts the oldest. Without a cap, a loop that opens one dialog per record grows the top layer without bound, and every dialog would add itself to the focus-trap work.
container must already be attached. A detached target throws rather than mounting an invisible dialog and leaving the page scroll-locked:
modal.open({ container: host.shadowRoot }); // host not in the document yet — throwsinitialFocus must be inside the dialog. The focus guard pulls focus back from anywhere else, so pointing it at an outside element has no lasting effect.
container must belong to the current document. A node from another document is refused rather than half-supported: focusin does not cross a document boundary either, so the focus guard would never fire.
content and footer nodes are moved, not copied. Passing the same Node to a second open() rips it out of the first dialog.
Store API
| Method | Returns | Notes |
|---|---|---|
open(options?) | ModalHandle | Opens a dialog. |
close(id?) | void | One by id, or every open dialog when omitted. |
closeAll() | void | Every open dialog. |
ModalHandle is { id, dialog, close() } — dialog is the <dialog> element, for content the options could not express. modal and useModalStore() hand back the same singleton; ModalService.getInstance() is the DI escape hatch.
const handle = modal.open({ title: "Loading" });
// later
handle.close();Content is never parsed as HTML
content and footer accept a string, and a string is always inserted with textContent. Markup in a server response or a user field renders as visible text, not as elements:
modal.open({ message: await res.text() }); // safeFor real markup, pass a Node you built yourself:
const form = document.createElement("form");
form.innerHTML = "<input name='email' required>"; // your markup, your rules
modal.open({ title: "Sign up", content: form });SSR
modal.* throws only when a method is called, never on import — so the facade is safe to pull into a module that also runs on the server. modalClass() and modalContainerClass() are pure and need no DOM.
Contract
Every component in the kit follows one shape. Pure builders return a class string and touch nothing; factories build a DOM element and need a browser.
| Surface | Import | Needs a DOM |
|---|---|---|
| Pure class builder | modalClass | No — safe during SSR |
| Element factory | modal.open | Yes — throws with a pointer to the pure surface |
// Server and templates — pure
<div class={ modalClass({ /* … */ }) }></div>
// Browser — imperative
document.body.append(modal.open({ /* … */ }));Class names are unprefixed. The root class is modal, not kk-modal, and every component rule is wrapped in @layer katana.
That matters because these names collide 1:1 with daisyUI. Unlayered CSS always beats layered CSS regardless of specificity or import order, so loading both is deterministic instead of "whoever came last". See why.