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.
Install
Section titled “Install”Requires Node.js 18+ (build tooling).
pnpm add @agencecinq/modalImport 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><dialog></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&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>Markup
Section titled “Markup”<cinq-modal>must contain a<dialog>(or an element marked with[data-dialog]).idis required for event-driven open/close (it must match thearia-controlsof 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&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>Attributes
Section titled “Attributes”| Attribute | Required | Description |
|---|---|---|
id |
Yes | Modal identifier (must match button aria-controls. |
open |
No | Reflected state attribute (useful for styling). |
Methods
Section titled “Methods”| 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. |
Events
Section titled “Events”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. |
Deferring open or close
Section titled “Deferring open or close”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.
Background scroll
Section titled “Background scroll”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.
Preferred: CSS
Section titled “Preferred: CSS”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.
Alternative: events + @agencecinq/utils
Section titled “Alternative: events + @agencecinq/utils”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.
Interactive example
Section titled “Interactive example”Playground (live)
Section titled “Playground (live)”Confirm dialog
A simple confirm/cancel flow using multiple <cinq-modal-button> tied to the same modal.
Waiting for your choice...
Inline form
A small form inside the modal. The dialog closes on submit and shows the result outside.
No character saved yet.
Stacked modals (W3C APG)
Section titled “Stacked modals (W3C APG)”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).
Replace: open next, close current
Section titled “Replace: open next, close current”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.
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.