Skip to content

HyDialog

A modal dialog component using the native HTML <dialog> element with showModal().

Native Dialog Approach

This implementation leverages the browser's native <dialog> element which provides:

  • Top Layer Rendering: Dialog appears above all other content automatically
  • Inert Background: Content outside the dialog is non-interactive
  • Backdrop Support: Native ::backdrop pseudo-element for overlay styling
  • Escape Key Handling: Built-in keyboard dismissal with the cancel event
  • Accessibility: Implicit role="dialog" and aria-modal="true"

Focus Trap (JS-supplemented)

Native <dialog>.showModal() makes background content inert, but Tab cycling within the dialog is browser-dependent — Firefox in particular lets focus drift into browser chrome before returning. On show, the dialog activates a JS focus trap (internal/focus-trap.ts) that intercepts Tab/Shift+Tab and cycles focus across the close button + all slotted focusables. On hide / disconnect, the trap is torn down so the trigger element can receive focus restoration.

Animation System

The dialog uses CSS transitions for smooth open/close animations:

  • Opening: Scale and opacity transition triggered via data-transitioning attribute
  • Closing: Reverse animation with transitionend event detection
  • Reduced Motion: Instant transitions for users who prefer reduced motion

Accessibility Features

  • Native dialog accessibility (role="dialog", aria-modal="true")
  • Configurable aria-labelledby and aria-label
  • Focus restoration to trigger element on close
  • Escape key dismissal with cancelable event
  • Backdrop click dismissal
  • Body scroll lock when open

Browser Compatibility

Native <dialog> with showModal() is supported in:

  • Chrome 37+ (August 2014)
  • Firefox 98+ (March 2022)
  • Safari 15.4+ (March 2022)
  • Edge 79+ (January 2020)

Examples

Basic Usage

html
<hy-dialog label="Confirm Action" open>
  <p>Are you sure you want to proceed?</p>
  <div slot="footer">
    <hy-button variant="secondary">Cancel</hy-button>
    <hy-button variant="primary">Confirm</hy-button>
  </div>
</hy-dialog>

Self-managing trigger (no open wiring)

html
<hy-dialog label="Confirm Action">
  <hy-button slot="trigger">Open dialog</hy-button>
  <p>Are you sure you want to proceed?</p>
</hy-dialog>

With Custom Header

html
<hy-dialog>
  <div slot="header">
    <hy-icon name="warning"></hy-icon>
    <span>Warning</span>
  </div>
  <p>This action cannot be undone.</p>
</hy-dialog>

Without Header (Headless)

html
<hy-dialog no-header label="Image Preview">
  <img src="preview.jpg" alt="Preview image" />
</hy-dialog>

Programmatic Control

javascript
<hy-button id="openBtn">Open Dialog</hy-button>
<hy-dialog id="myDialog" label="Settings">
<p>Configure your preferences here.</p>
</hy-dialog>

<script>
const dialog = document.getElementById('myDialog');
const openBtn = document.getElementById('openBtn');

openBtn.addEventListener('click', () => dialog.show());

// Listen for close requests (escapable)
dialog.addEventListener('request-close', (e) => {
if (hasUnsavedChanges) {
e.preventDefault(); // Keep dialog open
showUnsavedWarning();
}
});

// Listen for visibility changes
dialog.addEventListener('show', () => console.log('Opened'));
dialog.addEventListener('hide', () => console.log('Closed'));
</script>

With Described Content

html
<!-- Note: described-by works best when the referenced element is outside the dialog -->
<p id="delete-description" hidden>
  Deleting this item will permanently remove it from your account.
</p>
<hy-dialog label="Delete Item" described-by="delete-description">
  <p>Deleting this item will permanently remove it from your account.</p>
  <div slot="footer">
    <hy-button variant="destructive">Delete</hy-button>
  </div>
</hy-dialog>

Autofocus a slotted form control

html
<!-- The dialog discovers the [autofocus] descendant on `show` and focuses it.
For text-like controls, add `autoselect` to pre-select the value so the
user can type-to-replace (rename-style flow). -->
<hy-dialog label="Edit Name">
  <hy-text-input autofocus autoselect label="Name" value="John Doe"></hy-text-input>
  <div slot="footer">
    <hy-button variant="primary">Save</hy-button>
  </div>
</hy-dialog>

Autofocus by marker (preferred)

html
<!-- Mark the slotted control with `autofocus`; the dialog discovers and
focuses it on `show`. Pair with `autoselect` on text-like inputs to
pre-select the value (rename-style flow). -->
<hy-dialog label="Rename">
  <hy-text-input label="Name" value="Old name" autofocus autoselect></hy-text-input>
</hy-dialog>

Custom initial focus via cancelable event

javascript
<!-- For the rare case where the wanted target lives outside the dialog
or focus must depend on runtime state, listen for `initial-focus`
and call `event.preventDefault()` to opt out of the default. -->
<hy-dialog id="edit-dialog" label="Edit">
<input id="alt-target" />
</hy-dialog>
<script>
document.getElementById('edit-dialog').addEventListener('initial-focus', (e) => {
e.preventDefault();
document.getElementById('alt-target').focus();
});
</script>

Persistent Dialog (must use explicit close)

html
<!-- Backdrop-click and Escape are ignored; only the close button works -->
<hy-dialog label="Saving…" persistent>
  <p>Uploading your file. Please wait for this to finish.</p>
</hy-dialog>

Events

  • show - Fired when the dialog opens. Detail: { source: HyVisibilitySource }
  • hide - Fired when the dialog closes (after animation). Detail: { source: HyVisibilitySource }
  • request-close - Fired before closing; cancelable to prevent close. Detail: { source: HyVisibilitySource }
  • initial-focus - Fired on show before the autofocus discovery runs. Cancelable: call event.preventDefault() to skip the default focus and apply your own. Detail: HyInitialFocusDetail

Slots

  • trigger - An element that toggles the dialog on click (self-managing, no open wiring required). ARIA (aria-haspopup, aria-controls, aria-expanded) is applied automatically and focus returns to the trigger on close. A controlled open still wins when both are present.
  • default - The main content displayed in the dialog body
  • header - Custom header content that replaces the default title. Accessibility note: When using a custom header, the dialog uses aria-label (from the label prop) instead of aria-labelledby. Ensure your label prop accurately describes the dialog for screen readers.
  • footer - Footer content, typically action buttons

Methods

willUpdate()

Generates unique IDs before the first render to prevent update-after-update warnings. IDs are generated client-side only to prevent SSR/client hydration mismatches.

firstUpdated()

Resolve a for= trigger and apply initial trigger ARIA after the first render (the <dialog> id exists by now).

updated()

Single source of truth: all side effects for open/close happen here. The open property drives all dialog state changes.

disconnectedCallback()

Clean up when element is removed from the DOM. Restores body scroll if dialog was open when disconnected.

show()

Opens the dialog programmatically. Sets the open property to true; all side effects happen in updated().

hide()

Closes the dialog programmatically. Emits a cancelable request-close event first. If the event is prevented, the dialog remains open.

Parameters:

  • source - How the close was triggered (for event detail)

Built with Lit. Documented with VitePress.