Zero-dependency modals built on the native
<dialog> element — top layer, focus handled, promises
included. Click anything below — every card is live.
<script src="https://cdn.jsdelivr.net/gh/vanilla-ui-kit/components/modal/modal.js"></script>
Title, text content, a footer — Esc, backdrop, and ✕ all close it.
Modal.open({
title: 'Welcome aboard',
content: 'Plain string, rendered as text.',
buttons: [{ label: 'Got it', variant: 'primary' }]
})
Four widths; full takes over the viewport.
Modal.open({ title: 'Large', size: 'lg', … })
Promises resolve to boolean / string|null —
the result lands below.
Modal.confirm('Delete this file?')
.then(ok => ok && remove())
Modal.prompt({ message: 'New name:',
value: 'untitled.txt' })
.then(name => name !== null && rename(name))
Variants, close values, and onClick returning
false to keep it open.
buttons: [
{ label: 'Discard', variant: 'danger', close: 'discarded' },
{ label: 'Keep editing', onClick: () => false },
{ label: 'Save', variant: 'primary', close: 'saved' }
]
No JS: data-vmd-open on the trigger,
data-vmd-close inside. The hidden element is adopted and
put back on close.
<button data-vmd-open="#newsletter-modal">…</button>
<div id="newsletter-modal" hidden
data-vmd-title="Stay in the loop">
…
<button data-vmd-close>Maybe later</button>
</div>
No Esc, no backdrop click, no ✕ — only an explicit choice closes it.
Modal.open({
title: 'Session expired',
dismissible: false,
buttons: [{ label: 'Sign in', variant: 'primary' }]
})
Modal.open(opts) // build from options → handle { el, close(result), update(opts) }
new Modal(target, options?) // enhance existing (hidden) markup; also callable without `new`
Modal.alert(message | opts) // → Promise<void>
Modal.confirm(message | opts) // → Promise<boolean>
Modal.prompt(message | opts) // → Promise<string | null>
Modal.open(opts) — build a dialog from options; returns a handle immediately.new Modal(target, options?) — turn an existing hidden element into a dialog: on open() it is adopted into the dialog body and put back exactly where it came from on close (hidden / display:none restored too).alert/confirm/prompt (see Statics & helpers).data-vmd-open="#selector" triggers, no JS (see Declarative init).Built on the native <dialog> element (top layer, real inertness) with a transparent role="dialog" fallback. Multiple modals may be open at once; body scroll stays locked until the last one closes. CommonJS/AMD supported; SSR-safe — without a DOM, open() returns a dummy handle and the promise helpers resolve immediately (undefined / false / null).
Accepted by Modal.open(), the constructor, and the promise helpers. The first five are keys of Modal.defaults — change one there to affect every modal, e.g. Modal.defaults.size = 'lg'.
| Name | Type | Default | Description |
|---|---|---|---|
size | string | 'md' | 'sm' 360px · 'md' 480px · 'lg' 720px · 'full' (takes over the viewport). |
dismissible | boolean | true | Allow Esc, backdrop click, and the ✕ button. |
styles | boolean | true | false = headless, no CSS ever injected (see Theming). |
theme | string | 'auto' | 'auto' | 'light' | 'dark' — per call or via defaults (see Theming). |
labels | object | { ok: 'OK', cancel: 'Cancel', close: 'Close', dialog: 'Dialog' } | Overridable UI strings (globally via Modal.defaults.labels or per call); partial overrides merge. |
title | string | — | Heading text (rendered with textContent). |
content | string | Element | — | String (text by default, markup with html: true) or a DOM element — adopted into the dialog and put back on close. |
html | boolean | false | Opt-in to render string content as trusted markup. |
buttons | array | — | Footer buttons: [{ label, variant: 'primary' | 'default' | 'danger', onClick(handle), close }]. Return false from onClick to keep the modal open; close is the value passed to onClose. |
onOpen | function | — | fn(handle) after the dialog is shown. |
onClose | function | — | fn(result) — result is the closing button’s close value, else undefined. |
ariaLabel | string | — | Accessible name when there is no title. |
opener | Element | — | Element to return focus to on close (defaults to document.activeElement at open time). |
Helper-only options — message (the body text; alias of content) on all three helpers; danger (red confirm button) and okLabel on Modal.confirm; value, placeholder, and required (OK/Enter refuse an empty value) on Modal.prompt.
Instance methods (from new Modal(target, options?)):
| Method | Returns | Description |
|---|---|---|
open(extra?) | this | Open the dialog, adopting the target element as its body (its data-vmd-title is used when no title is set). extra merges over the constructor options for this open, e.g. { opener: buttonEl }. No-op while already open. |
close(result?) | this | Close the dialog; result is passed to onClose. The adopted element is put back (re-hidden) where it came from. |
isOpen() | boolean | Whether this instance’s dialog is currently open. |
Handle members (returned by Modal.open()):
| Member | Returns | Description |
|---|---|---|
el | Element | The root <dialog class="vmd"> (or fallback <div role="dialog">) element. |
close(result?) | undefined | Close the dialog; result is passed to onClose. |
update(opts) | handle | Merge new options and rebuild title/content/buttons in place; focus is re-seated only if the rebuild dropped it out of the dialog. No-op after close. |
| Member | Returns | Description |
|---|---|---|
Modal.open(opts) | handle | Build and show a dialog from options. |
Modal.alert(message | opts) | Promise<void> | One button (labels.ok); resolves when closed, however it closes. |
Modal.confirm(message | opts) | Promise<boolean> | Cancel + confirm buttons. true ONLY on the explicit confirm — Esc / backdrop / ✕ resolve false. Extra options: danger (red confirm), okLabel. |
Modal.prompt(message | opts) | Promise<string | null> | Text input, auto-focused (an existing default value is pre-selected); Enter submits; null on cancel/Esc/backdrop. Extra options: value, placeholder, required. |
Modal.get(target) | Modal | null | The instance bound to an element (or selector), or null. |
Modal.autoInit(root?) | Modal[] | Wire every [data-vmd-open] trigger under root (default: document). Runs automatically on DOMContentLoaded (and via VC.autoInit() when the core is loaded); call it for content added later. |
Modal.defaults | object | Global defaults — change any once, e.g. Modal.defaults.size = 'lg'. |
Modal.salt | string | false | CSS isolation namespace (default 'vc1'). Set your own token before the first modal, or false to disable. |
Modal.css | string | The full stylesheet, always rendered with the current salt — a starting point for headless styling. |
Modal.version | string | Library version. |
| Callback | Signature | Fires |
|---|---|---|
onOpen | fn(handle) | After the dialog is shown. |
onClose | fn(result) | When the dialog closes — result is the closing button’s close value, else undefined (Esc, backdrop, ✕, data-vmd-close). |
button onClick | fn(handle) | When that footer button is clicked; return false to keep the modal open (validation etc.), otherwise it closes with the button’s close value. |
Modal dispatches no public CustomEvents; the native <dialog>’s cancel/close events are handled internally so Esc respects dismissible and the exit animation still plays.
No JS required — a trigger points at a hidden element, which is adopted into a dialog and put back (re-hidden) on close:
| Attribute | Where | Meaning |
|---|---|---|
data-vmd-open="#terms" | trigger | Opens the referenced element as a modal on click; focus returns to the trigger on close. |
data-vmd-close | inside any modal | Any element with this attribute closes its modal on click. |
data-vmd-title | target element | Dialog heading (maps to title). |
data-vmd-size | target element | sm / md / lg / full (maps to size). |
data-vmd-dismissible="false" | target element | Disable Esc / backdrop / ✕ ("false" or "0" = false). |
<button data-vmd-open="#terms">Show terms</button> <div id="terms" hidden data-vmd-title="Terms of Service"> …any markup… <button data-vmd-close>Got it</button> </div>
Modal.autoInit(root?) wires triggers added after page load.
Auto light/dark with the family’s resolution order: <html data-theme> / data-bs-theme / .dark class → prefers-color-scheme, re-resolved live. Pin it with Modal.defaults.theme = 'dark' or per call. All colors are CSS custom properties on .vmd (dark values on .vmd[data-theme=dark]):
| Property | Role |
|---|---|
--vmd-accent | Primary buttons, focus rings. |
--vmd-on-accent | Text on primary/danger buttons. |
--vmd-danger | Danger buttons. |
--vmd-bg | Panel background. |
--vmd-surface | Plain button hover fill. |
--vmd-text | Text color. |
--vmd-muted | Body text, ✕ button. |
--vmd-faint | Borders, plain buttons. |
--vmd-backdrop | Backdrop scrim color. |
--vmd-shadow | Panel shadow. |
--vmd-radius | Panel corner radius. |
--vmd-font | Font stack. |
CSS isolation — dialogs render as class="vmd vc1" and structural rules ship salted, so host-page design systems can’t override them; the --vmd-* variable overrides above keep working (var definitions are deliberately unsalted). Custom token: Modal.salt = 'acme' before the first modal; disable with Modal.salt = false.
Headless — Modal.defaults.styles = false (or per call styles: false) injects no CSS, ever. You keep the full behavior (native <dialog>/fallback, focus management, scroll lock, promises, ARIA) and the markup contract: dialog.vmd.vmd-md.vmd-has-title[data-theme=dark] → .vmd-panel → .vmd-head (.vmd-title or .vmd-head-spacer, .vmd-x when dismissible), .vmd-body (.vmd-msg for string content, .vmd-input for Modal.prompt), .vmd-foot with .vmd-btn / .vmd-btn-primary / .vmd-btn-danger. Start from Modal.css (string) or dist/modal.css. With the VC core loaded, VC.config({ accent: … }) themes modals and every other family component in one call.
| Key | Action |
|---|---|
Esc | Closes the dialog — only when dismissible (prevented cleanly on the native path so the exit animation still plays). |
Tab / Shift+Tab | Cycle focus inside the dialog — the native top layer makes the rest of the page inert; the fallback uses a manual Tab trap. |
Enter (in a prompt’s input) | Submits the prompt (refused while empty when required). |
Enter / Space | Activate the focused footer, ✕, or data-vmd-close button. |
Native <dialog> + showModal() where supported; fallback is a role="dialog" overlay. aria-modal="true" and aria-labelledby point at the title (or aria-label when there is none). Focus moves into the dialog on open — [autofocus] first, then the body’s first field, then the primary button — and returns to the opener on close. :focus-visible outlines on every control; animations are disabled under prefers-reduced-motion: reduce. All UI strings are overridable via defaults.labels for localisation.
Set Modal.defaults.styles = false and no CSS is ever injected —
you keep the full behavior (native <dialog> top layer, focus management,
scroll lock, promises, ARIA) and the stable .vmd-* markup contract to style however
you like. Both demos below run in an <iframe>: the styled examples above have
already injected the stylesheet into this page, so a headless dialog out here would still match
those rules.
The raw browser rendering — click the button and you get the user-agent's own
<dialog> look: centered box, default border, dimmed backdrop. Esc, backdrop
click, and the footer buttons all still work.
Modal.defaults.styles = false // headless: zero CSS injected
Modal.open({
title: 'Raw dialog',
content: 'A native <dialog> in the top layer.',
buttons: [{ label: 'Cancel' }, { label: 'OK', variant: 'primary' }]
})
The same headless calls with every structural .vmd-* hook mapped to Tailwind
utilities via @apply — scrim, panel, head/body/foot, the prompt input, and all
three button variants; the .vmd-in/.vmd-out state classes drive the
transitions. The button fetches cdn.tailwindcss.com, so nothing loads until you ask.