Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Modal

Modal is a reusable dialog that can hold whatever content you give it, not tied to any specific copy or use case. It can be shown or hidden programmatically, and dispatches cancelable events when confirmed or canceled, so it works equally well as a plain confirmation dialog or as a shell around fully custom content.

Dependencies

eun-icon · if closeable eun-button · if closeable, or the footer is shown
Overview API Examples Accessibility
Delete item

This will permanently remove the item from your workspace.

When to use

Reach for modal when someone must respond before doing anything else on the page, such as confirming a destructive action, filling out a short form with no reason to navigate away, or reviewing a single piece of content up close. If you just need to tell someone something happened without stopping them, alert is the lighter, non-blocking fit. If what you need is persistent navigation or a task panel that stays open alongside the page, use drawer instead. See Alternatives below for a side by side comparison.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/modal.js";
import "@eunomia/elements/button.js";
<eun-button id="trigger">Delete item</eun-button>
<eun-modal
  id="confirm-delete"
  kind="alert"
  heading="Delete item"
  description="Are you sure? This action cannot be undone."
  confirm-label="Delete"
  confirm-variant="critical"
>
</eun-modal>
trigger.addEventListener("click", () => confirmDelete.show());
confirmDelete.addEventListener("eunconfirm", () => deleteItem());

eun-modal is never open by default, so control it through its instance methods, typically from a trigger's click handler. Importing the file registers <eun-modal> as a custom element, with no further setup needed.

Alternatives

You want .. Prefers To tell someone something happened, without stopping them from doing anything else eun-alert Persistent navigation or a task panel that stays open alongside the page eun-drawer

Guidance

  • Use kind="alert" for destructive/blocking confirmations (delete, discard, irreversible actions)
  • Open and close it declaratively with a command/commandfor invoker (eun-button, eun-fab) whenever the trigger doesn't need to run other logic first, preferred over a manual click handler or the Popover API's own popovertarget: see Invoker Commands API below
  • Gate eunconfirm/euncancel with preventDefault() while an async action is pending, then call hide() once it resolves
  • Keep the default slot focused on one task: a confirmation, a short form, or a single piece of content to review
  • Set hide-actions and fill the footer slot yourself for anything beyond a simple confirm/cancel pair
  • Nesting more than one level of modal-in-modal: prefer stepping through content inside a single dialog
  • Relying on close-on-backdrop for a destructive confirmation the user shouldn't dismiss lightly, consider setting it to false
  • Using eun-modal for non-blocking notifications: see Alternatives above

Live testing

Properties

Modal <eun-modal>

Attributes

NameTypeDefaultDescription
aria-labelstringThe accessible name of the dialog when no visible heading is set, such as a flush search shell. Ignored once heading is set, since that already provides one
confirm-close-headingstring'Discard changes?'The heading of the internal discard confirmation dialog
confirm-close-descriptionstring"You have unsaved changes. This action can't be undone."The description of the internal discard confirmation dialog
confirm-close-cancel-labelstring'Keep editing'The cancel label of the internal discard confirmation dialog
confirm-close-confirm-labelstring'Discard'The confirm label of the internal discard confirmation dialog
openbooleanfalseWhether the modal is open
headingstringAn optional heading, rendered above the body
descriptionstringAn optional short description, rendered below the heading
kind'dialog' | 'alert' | 'search''dialog'Alert renders the dialog as an alert dialog, for destructive or blocking confirmations. Search removes the surface's default padding for a flush command palette layout
close-on-backdropbooleantrueWhether clicking outside the dialog requests a close, subject to the dirty gate
dirtybooleanfalseMarks the modal as having unsaved changes, requiring confirmation before any close attempt succeeds
hide-actionsbooleanfalseHides the default confirm and cancel footer entirely, for fully custom content-driven dialogs
closeablebooleantrueShows the close button in the top right corner
cancel-labelstring'Cancel'The label of the default cancel button
confirm-labelstring'Confirm'The label of the default confirm button
confirm-variant'primary' | 'critical''primary'The color theme of the default confirm button
cancel-variant'primary' | 'secondary' | 'critical' | 'success' | 'warning'The color theme of the default cancel button. Left unset, it renders as a neutral gray ghost button
aria-label-closestring"Close"The accessible label of the close button

Import the exact TypeScript type behind any property above, see Types.

Slots

NameDescription
(default)The dialog's body content
footerReplaces the default confirm and cancel buttons entirely
confirm-closeOptional custom content for the internal discard confirmation dialog, rendered below its heading and description

Events

NameTypeDescription
eunconfirmConfirmEventFired when confirming. Cancelable to keep the modal open
euncancelDialogCancelEventFired right before the modal actually closes, whether from a button, the close button, Escape, an outside click, a request to close, or a confirmed discard. Cancelable to keep the modal open. Not fired for a close attempt that only opens the discard confirmation dialog
eundirtycloseDirtyCloseEventFired right before the internal discard confirmation dialog would open, on any close attempt while dirty is set. Cancelable, so a listener can show its own confirmation instead

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--modal-widthSets the width of the dialog panel
--modal-max-heightSets the maximum height of the dialog panel, defaulting to the viewport height minus a margin. The body scrolls internally once content exceeds it, while the heading, description, and actions stay fixed
--modal-paddingSets the padding of the dialog panel
--modal-gapSets the gap between the heading, body, and actions blocks
--modal-border-radiusSets the corner radius of the dialog panel
--modal-backgroundSets the background color of the dialog panel
--modal-header-borderSets the border between the heading and description and the body, or none to remove it. Only visible when heading or description is set
--modal-backdrop-colorSets the color of the backdrop behind the dialog panel
--modal-backdrop-blurSets the blur radius applied behind the backdrop. None by default
--modal-search-topFor the search kind only, sets the fixed distance from the top of the viewport to the dialog, since a search dialog never vertically centers and its top edge shouldn't drift as content grows or shrinks

Imperative API

eun-modal is never open by default, so control it through its instance methods, typically from a trigger's click handler.

<eun-button id="trigger">Delete item</eun-button>
<eun-modal
  id="confirm-delete"
  kind="alert"
  heading="Delete item"
  description="Are you sure? This action cannot be undone."
  confirm-label="Delete"
  confirm-variant="critical"
></eun-modal>
trigger.addEventListener("click", () => confirmDelete.show());
confirmDelete.addEventListener("eunconfirm", () => deleteItem());

Invoker Commands API

Prefer this over the imperative API above whenever the trigger doesn't need to run anything else first: eun-modal listens for the Invoker Commands API's command event on itself, so any invoker, eun-button, eun-fab, or a native <button>, can open and close it declaratively, with no <script> at all. It also replaces the Popover API's own popovertarget, which only ever covers popover-attributed targets, not a dialog like this one.

This will permanently remove the item from your workspace.

Delete item
<eun-modal
  id="delete-modal"
  heading="Delete item"
  confirm-label="Delete"
  confirm-variant="critical"
>
  <p>This will permanently remove the item from your workspace.</p>
</eun-modal>
<eun-button command="show-modal" commandfor="delete-modal">Delete item</eun-button>

show-modal calls show(), close calls hide(), and request-close calls requestClose(), so it respects dirty exactly like a call from your own code would. See the Invoker Commands API guide for how the attributes work, custom commands, and browser support.

Arbitrary content

Invite a teammate

Send an invitation email to join this workspace.

The default slot accepts any content, while the confirm/cancel footer stays generic and only needs the labels adapted:

<eun-modal heading="Invite a teammate" confirm-label="Send invite">
  <p>Send an invitation email to join this workspace.</p>
  <input type="email" placeholder="teammate@company.com" />
</eun-modal>
modal.addEventListener("eunconfirm", () => sendInvite());

Custom footer

Custom footer

Fill the footer slot with anything beyond a simple confirm/cancel pair.

Nope Sure

Fill the footer slot to override the default confirm/cancel buttons entirely, since providing content for a named slot already replaces its fallback content, so hide-actions isn't needed (and would actually be counterproductive here: it removes the footer slot's container along with the default buttons, so anything you put in slot="footer" would never be rendered at all). Reserve hide-actions for when you don't want an actions row rendered by eun-modal at all, e.g. embedding your own buttons directly in the default slot instead:

<eun-modal heading="Custom footer">
  <p>...</p>
  <div slot="footer">
    <eun-button appearance="ghost" id="cancel">Nope</eun-button>
    <eun-button id="confirm">Sure</eun-button>
  </div>
</eun-modal>
cancel.addEventListener("click", () => modal.cancel());
confirm.addEventListener("click", () => modal.confirm());

Unsaved changes

dirty gates every close attempt, whether it's the close button, Escape, an outside click, or a direct requestClose() call, behind an internal "Discard changes?" eun-modal — nested one level inside this one — instead of you reinventing that confirmation UI yourself. Under the hood, requestClose() does exactly this, in order:

  1. If dirty is set, dispatch a cancelable eundirtyclose. A listener can preventDefault() it to take over the confirmation UI entirely instead of the internal dialog (see Custom confirmation UI below). If not prevented, the internal confirmation dialog opens instead, and nothing closes yet.
  2. If dirty isn't set, or once discard is confirmed (through the internal dialog or a substituted one), dispatch a cancelable euncancel, same as a plain close.
  3. If not prevented, close.

Confirming discard clears dirty and closes the modal, while canceling the confirmation leaves the modal open, still dirty. Try it below: type in the field, then close the modal.

Open Cancel Save

This minimal reproducible setup is the entire wiring needed, nothing else — hideActions/a custom footer aren't required, dirty gates the default cancel button and the close (X) button too:

<eun-modal id="modal" heading="Edit note">
  <eun-input id="note" label="Note"></eun-input>
  <eun-button slot="footer" appearance="ghost" id="cancel">Cancel</eun-button>
  <eun-button slot="footer" id="save">Save</eun-button>
</eun-modal>
const modal = document.querySelector("#modal");
const note = document.querySelector("#note");

// Any input marks the modal dirty ; a real app would likely track this
// more precisely (e.g. compare against the loaded value), this just
// demonstrates the wiring.
note.addEventListener("eunchange", () => {
  modal.dirty = true;
});

// Cancel always goes through requestClose() — dirty decides whether that
// closes immediately or opens the confirmation first.
document.querySelector("#cancel").addEventListener("click", () => {
  modal.requestClose();
});

// Save bypasses the gate entirely : there's nothing to discard once saved.
document.querySelector("#save").addEventListener("click", () => {
  modal.dirty = false;
  modal.hide();
});

The confirmation dialog's own text is customizable via confirm-close-heading, confirm-close-description, confirm-close-cancel-label, and confirm-close-confirm-label. See the API tab for their defaults. It's a real nested eun-modal, so it also picks up a softly blurred backdrop (--modal-backdrop-blur; see Custom appearance above) by default, to set an important decision apart from the modal behind it.

Custom confirmation content

For anything beyond a heading/description pair, fill the confirm-close slot: it renders inside the internal confirmation dialog, below its heading and description, exactly like slotting content into eun-modal directly (see Arbitrary content above).

Open Cancel
Drafts are kept for 30 days even if discarded.
<eun-modal heading="Edit note" dirty>
  <eun-input label="Note"></eun-input>
  <eun-button slot="footer" appearance="ghost">Cancel</eun-button>
  <div slot="confirm-close">
    <eun-icon name="info" size="16"></eun-icon>
    Drafts are kept for 30 days even if discarded.
  </div>
</eun-modal>

Custom confirmation UI

For anything beyond styling the internal dialog, skip it entirely: preventDefault() the cancelable eundirtyclose to take over with whatever confirmation UI is needed, such as window.confirm(), a custom element, or an async flow, then call confirmDiscard() once the user has agreed to discard their changes. The internal confirmation eun-modal is never opened in that case.

Open Cancel Save
<eun-modal id="modal" heading="Edit note">
  <eun-input id="note" label="Note"></eun-input>
  <eun-button slot="footer" appearance="ghost" id="cancel">Cancel</eun-button>
  <eun-button slot="footer" id="save">Save</eun-button>
</eun-modal>
const modal = document.querySelector("#modal");
const note = document.querySelector("#note");

note.addEventListener("eunchange", () => {
  modal.dirty = true;
});

document.querySelector("#cancel").addEventListener("click", () => {
  modal.requestClose();
});

document.querySelector("#save").addEventListener("click", () => {
  modal.dirty = false;
  modal.hide();
});

// Take over the confirmation UI entirely : skip the internal eun-modal
// and ask through window.confirm() instead.
modal.addEventListener("eundirtyclose", (event) => {
  event.preventDefault();
  if (window.confirm("Discard your unsaved changes?")) {
    modal.confirmDiscard();
  }
});

Custom appearance

Custom appearance

Padding, gap, border radius, backdrop color, backdrop blur, and the header border are all overridden here.

<eun-modal
  heading="Custom appearance"
  style="
    --modal-padding: 24px;
    --modal-gap: 16px;
    --modal-border-radius: 24px;
    --modal-backdrop-color: rgba(20, 140, 210, 0.35);
    --modal-backdrop-blur: 6px;
    --modal-header-border: none;
  "
>
  <p>...</p>
</eun-modal>

--modal-backdrop-blur defaults to 0 (no blur). Set it to soften whatever sits behind the dialog into a frosted-glass look, on top of (or instead of) tinting it with --modal-backdrop-color.

--modal-header-border accepts any valid border shorthand, so none removes the line between the heading/description and the body entirely, while any other value (e.g. 2px dashed red) replaces it outright. Only visible when heading or description is set — there's nothing to draw a line under otherwise.

Full list in the API tab: --modal-width, --modal-max-height, --modal-padding, --modal-gap, --modal-border-radius, --modal-background, --modal-header-border, --modal-backdrop-color, and --modal-backdrop-blur.

Overflowing content

Long content

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.

Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.

At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.

Set --modal-max-height to cap the dialog's height (it defaults to the viewport height minus a margin). Once the slotted content overflows it, only the body scrolls internally. The heading/description above sit structurally outside the scrollable body rather than merely pinned atop it (try scrolling: they never move, and the scrollbar itself never crosses them), and the confirm/cancel footer below stays fixed too, since it also sits entirely outside the scrolling region:

<eun-modal heading="Terms of service" style="--modal-max-height: 16rem;">
  <p>... a lot of content ...</p>
</eun-modal>

Search and command palette

Open search
Esc

kind="search" removes the surface's default padding entirely, so slotted content controls its own spacing. Combined with hide-actions and closeable set to false (in JS, since a boolean attribute has no way to spell "false" in plain HTML, only presence/absence), this is a flush shell for a command-palette-style dialog: a search field up top (icon on the left, an Esc badge on the right, its own bottom border) pinned in place with position: sticky while the result list below scrolls past it. --modal-max-height is overridden here to 22rem so the list overflows and actually demonstrates that scroll, since eun-modal's body is the only part that ever scrolls, while a heading (or, here, the sticky field row) always stays in view. eun-search's panel mode is built exactly this way. See its own Examples tab for the complete, data-bound, filterable version instead of this static illustration.

<eun-modal kind="search" hide-actions>
  <div class="search-field-row">
    <eun-icon name="search" size="16"></eun-icon>
    <input placeholder="Search by keyword…" />
    <kbd>Esc</kbd>
  </div>
  <ul class="search-results-list">
    <!-- result rows -->
  </ul>
</eun-modal>
.search-field-row {
  position: sticky;
  top: 0;
  background: var(--modal-background, #fff);
  /* icon/input/kbd layout, border-bottom, etc. — see the demo's own <style> */
}

Gating the close

Both eunconfirm and euncancel are cancelable, so call preventDefault() to keep the modal open, e.g. while an async action is pending:

modal.addEventListener("eunconfirm", async (event) => {
  event.preventDefault();
  await saveChanges();
  modal.hide();
});

Escape, an outside click, the close (X) button, and the default cancel button all go through requestClose() (they aren't native light-dismiss), which defers to cancel() once there's nothing left to gate, so preventDefault() on euncancel keeps the modal open for all of those as well. Set close-on-backdrop="false" to disable the outside-click path specifically. See Unsaved changes above for gating a close attempt itself, before euncancel even fires, behind a confirmation dialog.

Keyboard interactions

Key Action
Escape Triggers requestClose(), gated behind dirty like any other close
Tab / Shift+Tab Cycles focus through the dialog's focusable content, wrapping at each end

Unlike <dialog>'s showModal(), the native Popover API doesn't natively trap focus inside the surface or make the rest of the page inert, so eun-modal implements both itself: Tabing past the last focusable element (or Shift+Tabing before the first) wraps back around to the other end instead of reaching content outside the dialog, and focus landing outside it by any other means (a stray .focus() call, a background click while close-on-backdrop is off) is pulled back in. Separately, every element outside the open dialog's own branch of the page is forced inert for as long as it's open — unreachable by pointer (including hover, not just clicks), keyboard, and assistive tech alike — restored exactly once the dialog closes. Both are shared, document-level logic (see dialog-stack.utils.ts) rather than per-instance, so they apply uniformly to every open dialog, including arbitrarily deep slotted content with its own nested shadow roots (e.g. eun-search's panel mode) and stacked dialogs (e.g. this modal's own internal discard-confirmation dialog, see Unsaved changes in the Examples tab).

Focus management

On open, focus moves to the cancel button (or the first [autofocus] element in the slotted content, or the surface itself if hide-actions is set and nothing is marked [autofocus]), matching the safer default of not landing focus on a destructive confirm action.

On close, however it closes (Escape, an outside click, cancel(), confirm(), or a plain hide()), focus returns to whatever had it right before the dialog opened, per the WAI-ARIA Dialog pattern. This is captured automatically the moment the popover actually opens (not when show() is called), so it's correct even when something other than a direct click opens it, e.g. eun-search's panel mode, where a global Ctrl+K/⌘K shortcut can open the dialog from anywhere on the page: closing it returns focus to wherever it was before the shortcut was pressed, not necessarily to eun-search's own trigger.

Aria attributes

Renders role="dialog" (or role="alertdialog" with kind="alert", for destructive confirmations, though kind="search" also renders role="dialog") with aria-modal="true", aria-labelledby wired to the heading, and aria-describedby wired to the description when set. When there's no heading (e.g. a flush kind="search" shell with no visible title), set a plain aria-label attribute on eun-modal itself instead. It's read directly and forwarded to the dialog, so the dialog still gets an accessible name even without a visible heading. eun-search's own panel mode does exactly this (see its Accessibility tab).

Header and scrolling

heading/description render structurally outside the scrollable body — a fixed flex sibling above it, not merely pinned atop it via position: sticky — so every modal gets the same "title stays visible while content scrolls" behavior, not just eun-search's panel field row. Because the header sits outside the scroll container entirely rather than just being stickied within it, the scrollbar itself is scoped to the body content alone and never runs across the header, and there's no risk of Tab-ing (or a slotted anchor's own focus-scroll) landing the first focusable element visually underneath it.

Reduced motion

The entrance/exit transition (fade + scale for the dialog, fade for the backdrop) collapses to near-instant under prefers-reduced-motion: reduce, rather than being skipped outright, since display/overlay still need an active transition to hook into (via allow-discrete) for the popover to delay top-layer removal correctly, so the duration is reduced to effectively zero instead of removing the transition declaration.

Reference links

WAI-ARIA Dialog (Modal) Pattern
HTML Specification: Popover API
WCAG 2.1: Animation from Interactions