Building a dialog component

A foundational overview of how to build color-adaptive, responsive, and accessible mini and mega modals with the <dialog> element.

In this post I want to share my thoughts on how to build color-adaptive, responsive, and accessible mini and mega modals with the <dialog> element. Try the demo and view the source!

Demonstration of the mega and mini dialogs in their light and dark themes.

If you prefer video, here's a YouTube version of this post:

Overview

The <dialog> element is great for in-page contextual information or action. Consider when the user experience can benefit from a same page action instead of multi-page action: perhaps because the form is small or the only action required from the user is confirm or cancel.

The <dialog> element has recently become stable across browsers:

Browser Support

  • 37
  • 79
  • 98
  • 15.4

Source

I found the element was missing a few things, so in this GUI Challenge I add the developer experience items I expect: additional events, light dismiss, custom animations, and a mini and mega type.

Markup

The essentials of a <dialog> element are modest. The element will automatically be hidden and has styles built in to overlay your content.

<dialog>
  …
</dialog>

We can improve this baseline.

Traditionally, a dialog element shares a lot with a modal, and often the names are interchangeable. I took the liberty here of using the dialog element for both small dialog popups (mini), as well as full page dialogs (mega). I named them mega and mini, with both dialogs slightly adapted for different use cases. I added a modal-mode attribute to allow you to specify the type:

<dialog id="MegaDialog" modal-mode="mega"></dialog>
<dialog id="MiniDialog" modal-mode="mini"></dialog>

Screenshot of both the mini and the mega dialogs in both light and dark themes.

Not always, but generally dialog elements will be used to gather some interaction information. Forms inside dialog elements are made to go together. It's a good idea to have a form element wrap your dialog content so that JavaScript can access the data the user has entered. Furthermore, buttons inside a form using method="dialog" can close a dialog without JavaScript and pass data.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    …
    <button value="cancel">Cancel</button>
    <button value="confirm">Confirm</button>
  </form>
</dialog>

Mega dialog

A mega dialog has three elements inside the form: <header>, <article>, and <footer>. These serve as semantic containers, as well as style targets for the presentation of the dialog. The header titles the modal and offers a close button. The article is for form inputs and information. The footer holds a <menu> of action buttons.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    <header>
      <h3>Dialog title</h3>
      <button onclick="this.closest('dialog').close('close')"></button>
    </header>
    <article>...</article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

The first menu button has autofocus and an onclick inline event handler. The autofocus attribute will receive focus when the dialog is opened, and I find it's best practice to put this on the cancel button, not the confirm button. This ensures that confirmation is deliberate and not accidental.

Mini dialog

The mini dialog is very similar to the mega dialog, it's just missing a <header> element. This allows it to be smaller and more inline.

<dialog id="MiniDialog" modal-mode="mini">
  <form method="dialog">
    <article>
      <p>Are you sure you want to remove this user?</p>
    </article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

The dialog element provides a strong foundation for a full viewport element that can collect data and user interaction. These essentials can make for some very interesting and powerful interactions in your site or app.

Accessibility

The dialog element has very good built-in accessibility. Instead of adding these features like I usually do, many are already there.

Restoring focus

As we did by hand in Building a sidenav component, it's important that opening and closing something properly puts focus on the relevant open and close buttons. When that sidenav opens, focus is put on the close button. When the close button is pressed, focus is restored to the button that opened it.

With the dialog element, this is built-in default behavior:

Unfortunately, if you want to animate the dialog in and out, this functionality is lost. In the JavaScript section I'll be restoring that functionality.

Trapping focus

The dialog element manages inert for you on the document. Before inert, JavaScript was used to watch for focus leaving an element, at which point it intercepts and puts it back.

Browser Support

  • 102
  • 102
  • 112
  • 15.5

Source

After inert, any parts of the document can be "frozen" insomuch that they are no longer focus targets or are interactive with a mouse. Instead of trapping focus, focus is guided into the only interactive part of the document.

Open and auto focus an element

By default, the dialog element will assign focus to the first focusable element in the dialog markup. If this isn't the best element for the user to default to, use the autofocus attribute. As described earlier, I find it's best practice to put this on the cancel button and not the confirm button. This ensures that confirmation is deliberate and not accidental.

Closing with the escape key

It's important to make it easy to close this potentially interruptive element. Fortunately, the dialog element will handle the escape key for you, freeing you from the orchestration burden.

Styles

There's an easy path to styling the dialog element and a hard path. The easy path is achieved by not changing the display property of the dialog and working with its limitations. I go down the hard path to provide custom animations for opening and closing the dialog, taking over the display property and more.

Styling with Open Props

To accelerate adaptive colors and overall design consistency, I've shamelessly brought in my CSS variable library Open Props. In addition to the free provided variables, I also import a normalize file and some buttons, both of which Open Props provides as optional imports. These imports help me focus on customizing the dialog and demo while not needing a lot of styles to support it and make it look good.

Styling the <dialog> element

Owning the display property

The default show and hide behavior of a dialog element toggles the display property from block to none. This unfortunately means it can't be animated in and out, only in. I'd like to animate both in and out, and the first step is to set my own display property:

dialog {
  display: grid;
}

By changing, and therefore owning, the display property value, as shown in the above CSS snippet, a considerable amount of styles needs managed in order to facilitate the proper user experience. First, the default state of a dialog is closed. You can represent this state visually and prevent the dialog from receiving interactions with the following styles:

dialog:not([open]) {
  pointer-events: none;
  opacity: 0;
}

Now the dialog is invisible and cannot be interacted with when not open. Later I'll add some JavaScript to manage the inert attribute on the dialog, ensuring that keyboard and screen-reader users also can't reach the hidden dialog.

Giving the dialog an adaptive color theme

Mega dialog showing the light and dark theme, demonstrating the surface colors.

While color-scheme opts your document into a browser-provided adaptive color theme to light and dark system preferences, I wanted to customize the dialog element more than that. Open Props provides a few surface colors that adapt automatically to light and dark system preferences, similar to using the color-scheme. These are great for creating layers in a design and I love using color to help visually support this appearance of layer surfaces. The background color is var(--surface-1); to sit on top of that layer, use var(--surface-2):

dialog {
  …
  background: var(--surface-2);
  color: var(--text-1);
}

@media (prefers-color-scheme: dark) {
  dialog {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }
}

More adaptive colors will be added later for child elements, such as the header and footer. I consider them extra for a dialog element, but really important in making a compelling and well designed dialog design.

Responsive dialog sizing

The dialog defaults to delegating its size to its contents, which is generally great. My goal here is to constrain the max-inline-size to a readable size (--size-content-3 = 60ch) or 90% of the viewport width. This ensures the dialog won't go edge to edge on a mobile device, and won't be so wide on a desktop screen that it's hard to read. Then I add a max-block-size so the dialog won't exceed the height of the page. This also means that we'll need to specify where the scrollable area of the dialog is, in case it's a tall dialog element.

dialog {
  …
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  overflow: hidden;
}

Notice how I have max-block-size twice? The first uses 80vh, a physical viewport unit. What I really want is to keep the dialog within relative flow, for international users, so I use the logical, newer, and only partially supported dvb unit in the second declaration for when it becomes more stable.

Mega dialog positioning

To assist in positioning a dialog element, it's worth breaking down its two parts: the full screen backdrop and the dialog container. The backdrop must cover everything, providing a shade effect to help support that this dialog is in front and the content behind is inaccessible. The dialog container is free to center itself over this backdrop and take whatever shape its contents require.

The following styles fix the dialog element to the window, stretching it to each corner, and uses margin: auto to center the content:

dialog {
  …
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
Mobile mega dialog styles

On small viewports, I style this full page mega modal a little differently. I set the bottom margin to 0, which brings the dialog content to the bottom of the viewport. With a couple of style adjustments, I can turn the dialog into an actionsheet, closer to the user's thumbs:

@media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    margin-block-end: 0;
    border-end-end-radius: 0;
    border-end-start-radius: 0;
  }
}

Screenshot of devtools overlaying margin spacing 
  on both the desktop and mobile mega dialog while open.

Mini dialog positioning

When using a larger viewport such as on a desktop computer, I chose to position the mini dialogs over the element that called them. To do this I need JavaScript. You can find the technique I use here, but I feel it is beyond the scope of this article. Without the JavaScript, the mini dialog appears in the center of the screen, just like mega dialog.

Make it pop

Last, add some flair to the dialog so it looks like a soft surface sitting far above the page. The softness is achieved by rounding the corners of the dialog. The depth is achieved with one of Open Props’ carefully crafted shadow props:

dialog {
  …
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
}

Customizing the backdrop pseudo element

I chose to work very lightly with the backdrop, only adding a blur effect with backdrop-filter to the mega dialog:

Browser Support

  • 76
  • 17
  • 103
  • 9

Source

dialog[modal-mode="mega"]::backdrop {
  backdrop-filter: blur(25px);
}

I also chose to put a transition on backdrop-filter, in the hope that browsers will allow transitioning the backdrop element in the future:

dialog::backdrop {
  transition: backdrop-filter .5s ease;
}

Screenshot of the mega dialog overlaying a blurred background of colorful avatars.

Styling extras

I call this section "extras" because it has more to do with my dialog element demo than it does the dialog element in general.

Scroll containment

When the dialog is shown, the user is still able to scroll the page behind it, which I do not want:

Normally, overscroll-behavior would be my usual solution, but according to the spec, it has no effect on the dialog because it's not a scroll port, that is, it's not a scroller so there's nothing to prevent. I could use JavaScript to watch for the new events from this guide, such as "closed" and "opened", and toggle overflow: hidden on the document, or I could wait for :has() to be stable in all browsers:

Browser Support

  • 105
  • 105
  • 121
  • 15.4

Source

html:has(dialog[open][modal-mode="mega"]) {
  overflow: hidden;
}

Now when a mega dialog is open, the html document has overflow: hidden.

The <form> layout

Besides being a very important element for collecting the interaction information from the user, I use it here to lay out the header, footer and article elements. With this layout I intend to articulate the article child as a scrollable area. I achieve this with grid-template-rows. The article element is given 1fr and the form itself has the same maximum height as the dialog element. Setting this firm height and firm row size is what allows the article element to be constrained and scroll when it overflows:

dialog > form {
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-items: start;
  max-block-size: 80vh;
  max-block-size: 80dvb;
}

Screenshot of devtools overlaying the grid layout information over the rows.

Styling the dialog <header>

The role of this element is to provide a title for the dialog content and offer an easy to find close button. It's also given a surface color to make it appear to be behind the dialog article content. These requirements lead to a flexbox container, vertically aligned items that are spaced to their edges, and some padding and gaps to give the title and close buttons some room:

dialog > form > header {
  display: flex;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  background: var(--surface-2);
  padding-block: var(--size-3);
  padding-inline: var(--size-5);
}

@media (prefers-color-scheme: dark) {
  dialog > form > header {
    background: var(--surface-1);
  }
}

Screenshot of Chrome Devtools overlaying flexbox layout information on the dialog header.

Styling the header close button

Since the demo is using the Open Props buttons, the close button is customized into a round icon centric button like so:

dialog > form > header > button {
  border-radius: var(--radius-round);
  padding: .75ch;
  aspect-ratio: 1;
  flex-shrink: 0;
  place-items: center;
  stroke: currentColor;
  stroke-width: 3px;
}

Screenshot of Chrome Devtools overlaying sizing and padding information for the header close button.

Styling the dialog <article>

The article element has a special role in this dialog: it's a space intended to be scrolled in the case of a tall or long dialog.

To accomplish this, the parent form element has established some maximums for itself which provide constraints for this article element to reach if it gets too tall. Set overflow-y: auto so scrollbars are only shown when needed, contain scrolling within it with overscroll-behavior: contain, and the rest will be custom presentation styles:

dialog > form > article {
  overflow-y: auto; 
  max-block-size: 100%; /* safari */
  overscroll-behavior-y: contain;
  display: grid;
  justify-items: flex-start;
  gap: var(--size-3);
  box-shadow: var(--shadow-2);
  z-index: var(--layer-1);
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: light) {
  dialog > form > article {
    background: var(--surface-1);
  }
}

The footer's role is to contain menus of action buttons. Flexbox is used to align the content to the end of the footer inline axis, then some spacing to give the buttons some room.

dialog > form > footer {
  background: var(--surface-2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: dark) {
  dialog > form > footer {
    background: var(--surface-1);
  }
}

Screenshot of Chrome Devtools overlaying flexbox layout information on the footer element.

The menu element is used to contain the action buttons for the dialog. It uses a wrapping flexbox layout with gap to provide space between the buttons. Menu elements have padding such as a <ul>. I also remove that style since I don't need it.

dialog > form > footer > menu {
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  padding-inline-start: 0;
}

dialog > form > footer > menu:only-child {
  margin-inline-start: auto;
}

Screenshot of Chrome Devtools overlaying flexbox information on the footer menu elements.

Animation

Dialog elements are often animated because they enter and exit the window. Giving dialogs some supportive motion for this entrance and exit helps users orient themselves in the flow.

Normally the dialog element can only be animated in, not out. This is because the browser toggles the display property on the element. Earlier, the guide set display to grid, and never sets it to none. This unlocks the ability to animate in and out.

Open Props comes with many keyframe animations for use, which makes orchestration easy and legible. Here are the animation goals and layered approach I took:

  1. Reduced motion is the default transition, a simple opacity fade in and out.
  2. If motion is ok, slide and scale animations are added.
  3. The responsive mobile layout for the mega dialog is adjusted to slide out.

A safe and meaningful default transition

While Open Props comes with keyframes for fading in and out, I prefer this layered approach of transitions as the default with keyframe animations as potential upgrades. Earlier we already styled the dialog’s visibility with opacity, orchestrating 1 or 0 depending on the [open] attribute. To transition between 0% and 100%, tell the browser how long and what kind of easing you'd like:

dialog {
  transition: opacity .5s var(--ease-3);
}

Adding motion to the transition

If the user is ok with motion, both the mega and the mini dialogs should slide up as their entrance, and scale out as their exit. You can achieve this with the prefers-reduced-motion media query and a few Open Props:

@media (prefers-reduced-motion: no-preference) {
  dialog {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  dialog[open] {
    animation: var(--animation-slide-in-up) forwards;
  }
}

Adapting the exit animation for mobile

Earlier in the styling section, the mega dialog style is adapted for mobile devices to be more like an action sheet, as if a small piece of paper has slid up from the bottom of the screen and is still attached to the bottom. The scale out exit animation doesn't fit this new design well, and we can adapt this with a couple media queries and some Open Props:

@media (prefers-reduced-motion: no-preference) and @media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    animation: var(--animation-slide-out-down) forwards;
    animation-timing-function: var(--ease-squish-2);
  }
}

JavaScript

There are quite a few things to add with JavaScript:

// dialog.js
export default async function (dialog) {
  // add light dismiss
  // add closing and closed events
  // add opening and opened events
  // add removed event
  // removing loading attribute
}

These additions stem from the desire for light dismiss (clicking the dialog backdrop), animation, and some additional events for better timing on getting the form data.

Adding light dismiss

This task is straightforward and a great addition to a dialog element that isn't being animated. The interaction is achieved by watching clicks on the dialog element and leveraging event bubbling to assess what was clicked, and will only close() if it's the top-most element:

export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
}

const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

Notice dialog.close('dismiss'). The event is called and a string is provided. This string can be retrieved by other JavaScript to get insights into how the dialog was closed. You'll find I've also provided close strings each time I call the function from various buttons, to provide context to my application about the user interaction.

Adding closing and closed events

The dialog element comes with a close event: it emits immediately when the dialog close() function is called. Since we're animating this element, it's nice to have events for before and after the animation, for a change to grab the data or reset the dialog form. I use it here to manage the addition of the inert attribute on the closed dialog, and in the demo I use these to modify the avatar list if the user has submitted a new image.

To achieve this, create two new events called closing and closed. Then listen for the built-in close event on the dialog. From here, set the dialog to inert and dispatch the closing event. The next task is to wait for the animations and transitions to finish running on the dialog, then dispatch the closed event.

const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')

export default async function (dialog) {
  …
  dialog.addEventListener('close', dialogClose)
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

The animationsComplete function, which is also used in the Building a toast component, returns a promise based on the completion of the animation and transition promises. This is why dialogClose is an async function; it can then await the promise returned and move forward confidently to the closed event.

Adding opening and opened events

These events aren't as easy to add since the built-in dialog element doesn't provide an open event like it does with close. I use a MutationObserver to provide insights into the dialog’s attributes changing. In this observer, I'll watch for changes to the open attribute and manage the custom events accordingly.

Similar to how we started the closing and closed events, create two new events called opening and opened. Where we previously listened for the dialog close event, this time use a created mutation observer to watch the dialog's attributes.

…
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')

export default async function (dialog) {
  …
  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })
}

const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

The mutation observer callback function will be called when the dialog attributes are changed, providing the list of changes as an array. Iterate over the attribute changes, looking for the attributeName to be open. Next, check if the element has the attribute or not: this informs whether or not the dialog has become open. If it has been opened, remove the inert attribute, set focus to either an element requesting autofocus or the first button element found in the dialog. Last, similar to the closing and closed event, dispatch the opening event right away, wait for the animations to finish, then dispatch the opened event.

Adding a removed event

In single page applications, dialogs are often added and removed based on routes or other application needs and state. It can be useful to clean up events or data when a dialog is removed.

You can achieve this with another mutation observer. This time, instead of observing attributes on a dialog element, we'll observe the children of the body element and watch for dialog elements being removed.

…
const dialogRemovedEvent = new Event('removed')

export default async function (dialog) {
  …
  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })
}

const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

The mutation observer callback is called whenever children are added or removed from the body of the document. The specific mutations being watched are for removedNodes that have the nodeName of a dialog. If a dialog was removed, the click and close events are removed to free up memory, and the custom removed event is dispatched.

Removing the loading attribute

To prevent the dialog animation from playing its exit animation when added to the page or on page load, a loading attribute has been added to the dialog. The following script waits for the dialog animations to finish running, then removes the attribute. Now the dialog is free to animate in and out, and we've effectively hidden an otherwise distracting animation.

export default async function (dialog) {
  …
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Learn more about the problem of preventing keyframe animations on page load here.

All together

Here is dialog.js in its entirety, now that we've explained each section individually:

// custom events to be added to <dialog>
const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')
const dialogRemovedEvent = new Event('removed')

// track opening
const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

// track deletion
const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

// wait for all dialog animations to complete their promises
const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

// click outside the dialog handler
const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

// page load dialogs setup
export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
  dialog.addEventListener('close', dialogClose)

  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })

  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })

  // remove loading attribute
  // prevent page load @keyframes playing
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Using the dialog.js module

The exported function from the module expects to be called and passed a dialog element that wants to have these new events and functionality added:

import GuiDialog from './dialog.js'

const MegaDialog = document.querySelector('#MegaDialog')
const MiniDialog = document.querySelector('#MiniDialog')

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

Just like that, the two dialogs are upgraded with light dismiss, animation loading fixes, and more events to work with.

Listening to the new custom events

Each upgraded dialog element can now listen for five new events, like this:

MegaDialog.addEventListener('closing', dialogClosing)
MegaDialog.addEventListener('closed', dialogClosed)

MegaDialog.addEventListener('opening', dialogOpening)
MegaDialog.addEventListener('opened', dialogOpened)

MegaDialog.addEventListener('removed', dialogRemoved)

Here are two examples of handling those events:

const dialogOpening = ({target:dialog}) => {
  console.log('Dialog opening', dialog)
}

const dialogClosed = ({target:dialog}) => {
  console.log('Dialog closed', dialog)
  console.info('Dialog user action:', dialog.returnValue)

  if (dialog.returnValue === 'confirm') {
    // do stuff with the form values
    const dialogFormData = new FormData(dialog.querySelector('form'))
    console.info('Dialog form data', Object.fromEntries(dialogFormData.entries()))

    // then reset the form
    dialog.querySelector('form')?.reset()
  }
}

In the demo that I built with the dialog element, I use that closed event and the form data to add a new avatar element to the list. The timing is good in that the dialog has completed its exit animation, and then some scripts animate in the new avatar. Thanks to the new events, orchestrating the user experience can be smoother.

Notice dialog.returnValue: this contains the close string passed when the dialog close() event is called. It's critical in the dialogClosed event to know if the dialog was closed, canceled, or confirmed. If it's confirmed, the script then grabs the form values and resets the form. The reset is useful so that when the dialog is shown again, it's blank and ready for a new submission.

Conclusion

Now that you know how I did it, how would you‽ 🙂

Let's diversify our approaches and learn all the ways to build on the web.

Create a demo, tweet me links, and I'll add it to the community remixes section below!

Community remixes

Resources