Vanilla UI Kit Modal v1.0.0 · toast → · datepicker →

Dialogs, one file.

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>

Basic

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' }]
})

Sizes

Four widths; full takes over the viewport.

Modal.open({ title: 'Large', size: 'lg', … })

Confirm & prompt

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))

Custom footer buttons

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' }
]

Declarative

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>

Non-dismissible

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' }]
})

API reference

Constructor & usage

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>

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

Options

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

NameTypeDefaultDescription
sizestring'md''sm' 360px · 'md' 480px · 'lg' 720px · 'full' (takes over the viewport).
dismissiblebooleantrueAllow Esc, backdrop click, and the ✕ button.
stylesbooleantruefalse = headless, no CSS ever injected (see Theming).
themestring'auto''auto' | 'light' | 'dark' — per call or via defaults (see Theming).
labelsobject{ ok: 'OK', cancel: 'Cancel', close: 'Close', dialog: 'Dialog' }Overridable UI strings (globally via Modal.defaults.labels or per call); partial overrides merge.
titlestring—Heading text (rendered with textContent).
contentstring | Element—String (text by default, markup with html: true) or a DOM element — adopted into the dialog and put back on close.
htmlbooleanfalseOpt-in to render string content as trusted markup.
buttonsarray—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.
onOpenfunction—fn(handle) after the dialog is shown.
onClosefunction—fn(result) — result is the closing button’s close value, else undefined.
ariaLabelstring—Accessible name when there is no title.
openerElement—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.

Methods

Instance methods (from new Modal(target, options?)):

MethodReturnsDescription
open(extra?)thisOpen 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?)thisClose the dialog; result is passed to onClose. The adopted element is put back (re-hidden) where it came from.
isOpen()booleanWhether this instance’s dialog is currently open.

Handle members (returned by Modal.open()):

MemberReturnsDescription
elElementThe root <dialog class="vmd"> (or fallback <div role="dialog">) element.
close(result?)undefinedClose the dialog; result is passed to onClose.
update(opts)handleMerge 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.

Statics & helpers

MemberReturnsDescription
Modal.open(opts)handleBuild 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 | nullThe 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.defaultsobjectGlobal defaults — change any once, e.g. Modal.defaults.size = 'lg'.
Modal.saltstring | falseCSS isolation namespace (default 'vc1'). Set your own token before the first modal, or false to disable.
Modal.cssstringThe full stylesheet, always rendered with the current salt — a starting point for headless styling.
Modal.versionstringLibrary version.

Events & callbacks

CallbackSignatureFires
onOpenfn(handle)After the dialog is shown.
onClosefn(result)When the dialog closes — result is the closing button’s close value, else undefined (Esc, backdrop, ✕, data-vmd-close).
button onClickfn(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.

Declarative init

No JS required — a trigger points at a hidden element, which is adopted into a dialog and put back (re-hidden) on close:

AttributeWhereMeaning
data-vmd-open="#terms"triggerOpens the referenced element as a modal on click; focus returns to the trigger on close.
data-vmd-closeinside any modalAny element with this attribute closes its modal on click.
data-vmd-titletarget elementDialog heading (maps to title).
data-vmd-sizetarget elementsm / md / lg / full (maps to size).
data-vmd-dismissible="false"target elementDisable 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.

Theming

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]):

PropertyRole
--vmd-accentPrimary buttons, focus rings.
--vmd-on-accentText on primary/danger buttons.
--vmd-dangerDanger buttons.
--vmd-bgPanel background.
--vmd-surfacePlain button hover fill.
--vmd-textText color.
--vmd-mutedBody text, ✕ button.
--vmd-faintBorders, plain buttons.
--vmd-backdropBackdrop scrim color.
--vmd-shadowPanel shadow.
--vmd-radiusPanel corner radius.
--vmd-fontFont 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.

Accessibility

KeyAction
EscCloses the dialog — only when dismissible (prevented cleanly on the native path so the exit animation still plays).
Tab / Shift+TabCycle 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 / SpaceActivate 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.

Headless mode

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.

Unstyled (styles: false)

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' }]
})

Bring your own design (Tailwind)

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.