Skip to content
0

Modal new ​

ts
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:

ts
const host = document.querySelector("#drawer-host");
modal.open({ title: "Rename", container: host });

Focus ​

BehaviourWhat happens
On openFocus moves to initialFocus, else the first focusable child, else the dialog
Tab / Shift+TabCycles inside the dialog and wraps at both ends
Focus escapesA document-level focusin guard pulls it back
On closeFocus returns to whatever was focused before open()
Nested dialogsClosing 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 ​

OptionTypeDefaultNotes
titlestring—Rendered as <h2> and wired via aria-labelledby.
ariaLabelstring"Dialog"Accessible name. Set it when there is no title.
messagestring—Short body. Ignored when content is set.
contentstring | Node—Arbitrary body. Strings go through textContent, never HTML.
footerstring | Node—Footer row. Same insertion rules as content.
size"sm" | "md" | "lg" | "full""md"Validated at runtime, not trusted.
dismissiblebooleantrueRenders the close button.
dismissOnEscapebooleantrueEscape and backdrop clicks.
closeLabelstring"Close dialog"Accessible name for the close button.
containerHTMLElement | nulldocument.bodyPortal target. Must be in the document.
initialFocusHTMLElement | nullfirst focusableWhere focus lands on open. Must be inside the dialog.
returnFocusbooleantrueRestore 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:

ts
modal.open({ container: host.shadowRoot }); // host not in the document yet — throws

initialFocus 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 ​

MethodReturnsNotes
open(options?)ModalHandleOpens a dialog.
close(id?)voidOne by id, or every open dialog when omitted.
closeAll()voidEvery 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.

ts
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:

ts
modal.open({ message: await res.text() }); // safe

For real markup, pass a Node you built yourself:

ts
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.

SurfaceImportNeeds a DOM
Pure class buildermodalClassNo — safe during SSR
Element factorymodal.openYes — 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.

Released under the MIT License.