Architecture
Architecture
The package is built around three layers: a per-group host, a per-modal wrapper, and presentational primitives you compose inside your modal component.
Three layers
<ModalTarget group="..."> ← group host (fixed, full-viewport)
<ModalOverlay /> ← optional backdrop
<YourModal>
<ModalRoot> ← per-modal wrapper
<ModalContent> ← focus trap, ARIA, exit-animation handling
<!-- your markup -->
</ModalContent>
</ModalRoot>
</YourModal>
</ModalTarget>
| Layer | Component | Responsibility |
|---|---|---|
| Host | <ModalTarget> | Mounts modals of one group. Owns scroll lock, press-outside, stack sequencing. |
| Wrapper | <ModalRoot> | Per-modal layer. Provides focus trap, ARIA, Esc routing. |
| Primitives | <ModalContent>, <ModalOverlay>, <ModalTitle>, <ModalDescription> | Zero-style elements with data-state hooks for your CSS. |
What happens when you open a modal
When you call openModal(Component, options):
- The target for that group mounts your component automatically.
<ModalRoot>+<ModalContent>set up focus trap, ARIA, and Esc/overlay handling.- Body scroll is locked while any modal in that group is open.
- When you call
confirm(data)orclose(), the package waits for your CSS exit animation, then unmounts and settles the promise.
You write the modal component and call openModal. Mounting, focus, scroll, and animation timing are handled.
Layout contract
Each layer ships a tiny CSS reset using :where() (specificity 0), so your own styles override without !important.
:where([data-modal-region]) {
position: fixed;
inset: 0;
pointer-events: none;
}
:where([data-modal-root]) {
position: absolute;
inset: 0;
pointer-events: auto;
}
:where([data-modal-overlay]) {
position: absolute;
inset: 0;
pointer-events: none;
}
The region is fixed and full-viewport, but does not block the page when empty (pointer-events: none). Each mounted modal re-enables pointer events on its own root. The overlay is purely visual — close-on-overlay-click is handled at the modal layer, so it works whether or not you render <ModalOverlay>.
Data attributes
| Attribute | Element | Values | Purpose |
|---|---|---|---|
data-modal-region | <ModalTarget> | — | CSS scope for the host. |
data-modal-root | <ModalRoot> | — | CSS scope per modal. |
data-modal-content | <ModalContent> | — | Style the visible card. |
data-modal-overlay | <ModalOverlay> | — | Style the backdrop. |
data-state | <ModalContent>, <ModalOverlay> | "open" | "closed" | Drive enter/exit animations. Set to "closed" while the exit animation plays. |
data-instant | <ModalContent>, <ModalOverlay> | "" | absent | Present for the lifetime of a modal opened with instantEnter: true. Built-in CSS scoped to [data-state="open"] suppresses only the enter animation. |
Stack behavior
Modals in one group form a stack. Underlying modals stay mounted (preserving form values, scroll, and local state) but switch to data-state="closed" so your CSS hides or animates them out. Only the topmost modal reacts to Esc, overlay clicks, and Tab/focus.
When the top modal closes or another opens on top, the package waits for the outgoing modal's exit animation before promoting the next one — you never see two modals "open" mid-transition.
