Skip to content

Modal

A modal presents content in a layer above the page. <cinq-modal> wraps a native <dialog> element, wires showModal() / close(), focus management, and open/close events. <cinq-modal-button> connects triggers through aria-controls.

Requires Node.js 18+ (build tooling).

Terminal window
pnpm add @agencecinq/modal

Import once:

import "@agencecinq/modal";

Then write the markup. A native <dialog> inside <cinq-modal>, and buttons with matching aria-controls. The component adds interactivity only.

HTML is the source of truth. The component will not auto-set role, auto-migrate attributes, or warn about missing labels. Run an a11y linter (axe, Lighthouse) to catch invalid markup.

<cinq-modal-button class="block">
<button aria-controls="demo-modal" aria-pressed="false">Open modal</button>
</cinq-modal-button>
<cinq-modal id="demo-modal">
<dialog
class="fixed left-1/2 top-1/2 w-[min(92vw,32rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-2xl backdrop:bg-black/50 text-gray-900"
>
<h2 class="text-xl font-semibold mb-2">Monster entry</h2>
<p class="text-gray-800 mb-4">
This is a native <code>&lt;dialog&gt;</code> modal.
</p>
<p class="text-gray-800 mb-4">
The dracolich is an undead dragon created by forbidden rites. Opening this
modal feels like leafing through the AD&amp;D Monster Manual.
</p>
<cinq-modal-button class="block">
<button aria-controls="demo-modal" aria-pressed="false">Close</button>
</cinq-modal-button>
</dialog>
</cinq-modal>
  • <cinq-modal> must contain a <dialog> (or an element marked with [data-dialog]).
  • id is required for event-driven open/close (it must match the aria-controls of your buttons).

Copy‑paste starter markup:

<cinq-modal-button class="block">
<button
type="button"
aria-controls="example-modal"
aria-pressed="false"
>
Open modal
</button>
</cinq-modal-button>
<cinq-modal id="example-modal">
<dialog
class="fixed left-1/2 top-1/2 w-[min(92vw,32rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-2xl backdrop:bg-black/50 text-gray-900"
>
<h2 class="text-xl font-semibold mb-2">Dracolich lore</h2>
<p class="text-gray-800 mb-4">
The dracolich is an undead dragon created by forbidden rites. Opening this
modal feels like leafing through the AD&amp;D Monster Manual.
</p>
<cinq-modal-button class="block">
<button
type="button"
aria-controls="example-modal"
aria-pressed="false"
class="px-4 py-2 rounded-lg bg-black text-white hover:bg-gray-800"
>
Close
</button>
</cinq-modal-button>
</dialog>
</cinq-modal>
Attribute Required Description
id Yes Modal identifier (must match button aria-controls.
open No Reflected state attribute (useful for styling).
Method Description
show() Opens the native dialog. Returns false if already open, aborted, or deferred.
close() Closes the dialog. Returns false if already closed, aborted, or deferred.

Prefer the constants from @agencecinq/utils (EVENTS.MODAL_*). Events are dispatched on document.documentElement.

Event Cancelable Detail payload Description
modal:toggle No { modal, trigger, trap } Request open/close from a button.
modal:before-open Yes { modal, instance, trigger, resolve } Fired before open is set. Cancel to defer, then call resolve() to commit.
modal:before-close Yes { modal, instance, resolve } Fired before open is removed. Cancel to defer, then call resolve() to commit.
modal:open No { modal, trigger? } Fired after open is set (showModal(), focus).
modal:close No { modal } Fired after open is removed.

Every open path (toggle or show()) dispatches cancelable modal:before-open first. Every close path (backdrop click, Escape, or close()) dispatches cancelable modal:before-close first. Without a listener (or without preventDefault()), the modal opens or closes immediately. To run async work, cancel the event and call detail.resolve() when ready:

import { EVENTS } from "@agencecinq/utils";
document.documentElement.addEventListener(EVENTS.MODAL_BEFORE_OPEN, (event) => {
if (event.detail.modal !== "my-modal") return;
event.preventDefault();
void doAsyncWork().then(() => {
event.detail.resolve();
});
});
document.documentElement.addEventListener(EVENTS.MODAL_BEFORE_CLOSE, (event) => {
if (event.detail.modal !== "my-modal") return;
event.preventDefault();
void doAsyncWork().then(() => {
event.detail.resolve();
});
});

resolve() is idempotent: safe to call more than once. modal:open and modal:close fire only after the matching resolve().

UX: defer open with before-open when the fetch is short (roughly under 500 ms) and the dialog would feel broken if empty: show loading on the trigger while you wait, then call resolve(). For slower or unpredictable loads, skip the defer: let the modal open immediately and fetch on modal:open with a skeleton or spinner inside the dialog. Defer close with before-close when you need to save, archive, or run an exit animation before dismissing.

TypeScript: import BeforeOpenDetail and BeforeCloseDetail from @agencecinq/modal to type listeners.

Not handled by the package. Native showModal() makes the page inert (no focus / pointer outside the dialog) but does not lock document scroll. A wheel over the backdrop can still move the page underneath.

That lock is a document / theme concern (layout, scrollbar gutter, iOS quirks), so the consumer owns it. Same idea as styling the backdrop.

html:has(dialog[open]:modal) {
overflow: hidden;
scrollbar-gutter: stable;
}

Works with stacked modals: as long as any modal dialog is open, scroll stays locked.

If you need the shared disableScroll / enableScroll helpers (e.g. Shopify theme already using them), listen on document.documentElement and refcount open modals so closing the top of a stack does not unlock too early:

import { EVENTS, disableScroll, enableScroll } from "@agencecinq/utils";
let openCount = 0;
document.documentElement.addEventListener(EVENTS.MODAL_OPEN, () => {
if (openCount === 0) disableScroll();
openCount += 1;
});
document.documentElement.addEventListener(EVENTS.MODAL_CLOSE, () => {
openCount = Math.max(0, openCount - 1);
if (openCount === 0) enableScroll(false);
});

Keep the dialog content scrollable when it overflows. Only lock the page behind.

Dracolich lore

The dracolich retains the spellcasting and breath weapon of its original form, but its eyes burn with cold, undead hatred.

Confirm dialog

A simple confirm/cancel flow using multiple <cinq-modal-button> tied to the same modal.

Waiting for your choice...

Memorize fireball?

Memorizing fireball uses one of your wizard's 3rd-level spell slots for the day.

Inline form

A small form inside the modal. The dialog closes on submit and shows the result outside.

No character saved yet.

Character sheet

Enter a name and class for your adventurer.

Following the Dialog (Modal) pattern: a dialog may open over another. Content underneath is inert. Closing the top dialog returns focus to its invoker (e.g. Verify address inside the parent). Closing the parent returns focus to Add delivery address on the page.

Stacked modals (APG)

A modal can open over another. The lower dialog stays open but inert (native showModal() top layer). Closing the top dialog returns focus to the control that opened it. Inside the parent modal, not the page.

Red ring = aria-pressed="false", green = aria-pressed="true". Flow: open address to verify to close verify (focus on Verify) to close address (focus on Add delivery address).

Add delivery address

Street and city for the next encounter drop.

Verification result

Address looks valid. Close this dialog to return to the form. The address dialog stays open underneath.

Not a stack: Continue lists both ids (parent then child in aria-controls). The parent closes, then the child opens. Closing the child returns focus to Start checkout on the page (native restore after the parent already returned focus there).

Replace: open next, close current

From the first modal, Continue targets both ids (aria-controls="...address ...confirm"). Toggle order closes the parent then opens the child. Not a stack. Closing confirm returns focus to Start checkout.

Flow: Start checkout to Continue to Cancel/Confirm to focus back on Start checkout.

Delivery address

Step 1 of 2. Continue replaces this dialog with the confirmation.

Confirm order

Step 2 of 2. The address dialog is closed. Only this one is open.

Async bestiary (before-open / before-close)

Section titled “Async bestiary (before-open / before-close)”

Try opening and closing the modal below. Each transition is deferred until async work finishes: the creature loads before showModal(), then archives before the dialog closes.

Bestiary modal

Open the modal to load a creature. Close it to archive the entry.

Monster entry

Creature

Pick a creature and open the modal.