Dialog

This pattern shows how to create color-adaptive, responsive, and accessible mini and mega modals with the <dialog> element.

Full article · Video on YouTube · Source on Github

HTML

<dialog id="MegaDialog" inert loading modal-mode="mega"> 
  <form method="dialog">
    <header> 
      <section class="icon-headline">
        <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
          <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
          <circle cx="8.5" cy="7" r="4"></circle>
          <line x1="20" y1="8" x2="20" y2="14"></line>
          <line x1="23" y1="11" x2="17" y2="11"></line>
        </svg>
        <h3>New User</h3>
      </section>
      <!-- TODO: Devsite - Removed inline handlers -->
      <!-- <button onclick="this.closest('dialog').close('close')" type="button" title="Close dialog">  -->
        <title>Close dialog icon</title>
        <svg width="24" height="24" viewBox="0 0 24 24">
          <line x1="18" y1="6" x2="6" y2="18"/>
          <line x1="6" y1="6" x2="18" y2="18"/>
        </svg>
      </button>
    </header>
    <article>
      <section class="labelled-input">
        <label for="userimage">Upload an image</label>
        <input id="userimage" name="userimage" type="file">
      </section>
      <small><b>*</b> Maximum upload 1mb</small>
    </article>
    <footer>
      <menu>
        <button type="reset" value="clear">Clear</button>
      </menu>
      <menu>
        <!-- TODO: Devsite - Removed inline handlers -->
        <!-- <button autofocus type="button" onclick="this.closest('dialog').close('cancel')">Cancel</button> -->
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

<dialog id="MiniDialog" inert loading modal-mode="mini"> 
  <form method="dialog">
    <article>
      <section class="warning-message">
        <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" >
          <title>A warning icon</title>
          <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
          <line x1="12" y1="9" x2="12" y2="13"></line>
          <line x1="12" y1="17" x2="12.01" y2="17"></line>
        </svg>
        <p>Are you sure you want to remove this user?</p>
      </section>
    </article>
    <footer>
      <menu>
        <!-- TODO: Devsite - Removed inline handlers -->
        <!-- <button autofocus type="button" onclick="this.closest('dialog').close('cancel')">Cancel</button> -->
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

CSS


        @import "https://unpkg.com/open-props";
@import "https://unpkg.com/open-props/normalize.min.css";

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

dialog {
  display: grid;
  background: var(--surface-2);
  color: var(--text-1);
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
  z-index: var(--layer-important);
  overflow: hidden;
  transition: opacity .5s var(--ease-3);

  @media (--motionOK) {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  @media (--OSdark) {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }

  @media (--md-n-below) {
    &[modal-mode="mega"] {
      margin-block-end: 0;
      border-end-end-radius: 0;
      border-end-start-radius: 0;

      @media (--motionOK) {
        animation: var(--animation-slide-out-down) forwards;
        animation-timing-function: var(--ease-squish-2);
      }
    }
  }

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

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

  &[modal-mode="mini"]::backdrop {
    backdrop-filter: none;
  }

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

  &[loading] {
    visibility: hidden;
  }

  &[open] {
    @media (--motionOK) {
      animation: var(--animation-slide-in-up) forwards;
    }
  }

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

    & > 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 (--OSlight) {
        background: var(--surface-1);

        &::-webkit-scrollbar {
          background: var(--surface-1);
        }
      }

      @media (--OSdark) {
        border-block-start: var(--border-size-1) solid var(--surface-3);
      }
    }

    & > header {
      display: flex;
      gap: var(--size-3);
      justify-content: space-between;
      align-items: flex-start;
      padding-block: var(--size-3);
      padding-inline: var(--size-5);

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

    & > footer {
      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);

      & > menu {
        display: flex;
        flex-wrap: wrap;
        gap: var(--size-3);
        padding-inline-start: 0;

        &:only-child {
          margin-inline-start: auto;
        }

        @media (max-width: 410px) {
          & button[type="reset"] {
            display: none;
          }
        }
      }
    }

    & > :is(header, footer) {
      background-color: var(--surface-2);

      @media (--OSdark) {
        background-color: var(--surface-1);
      }
    }
  }
}
        

JS


        // custom events to be added to 
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')
}