komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Components & Islands••By Komal Nakrani

An Astro Modal Built on the dialog Element

Build an Astro modal with the native dialog element: showModal() gives focus trapping, Escape to close and a backdrop for free. Copy-paste component and styles.

#Astro#Components#Modal#Accessibility

The best Astro modal is the native <dialog> element. Calling dialog.showModal() makes the rest of the page inert, keeps keyboard focus inside the dialog, closes on Escape, and draws a styleable ::backdrop, all handled by the browser. This component wraps it with a trigger button, a close button, an optional click-outside-to-close, and focus returned to the trigger afterwards.

Key Takeaways

  • showModal() provides focus trapping, Escape handling and inertness; don’t rebuild them.
  • Label the dialog with aria-labelledby pointing at its heading.
  • A <form method="dialog"> button closes the dialog with no script.
  • Return focus to the element that opened it (browsers do this; the component makes it explicit).

The component

---
// src/components/Modal.astro
interface Props { id: string; title: string; triggerLabel: string }
const { id, title, triggerLabel } = Astro.props;
---
<button type="button" class="modal-trigger" data-modal-open={id}>{triggerLabel}</button>

<dialog id={id} class="modal" aria-labelledby={`${id}-title`}>
  <div class="modal-inner">
    <header class="modal-header">
      <h2 id={`${id}-title`}>{title}</h2>
      <form method="dialog">
        <button class="modal-close" aria-label="Close">
          <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
            <path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="2" />
          </svg>
        </button>
      </form>
    </header>
    <div class="modal-body"><slot /></div>
  </div>
</dialog>

<style>
  .modal {
    width: min(32rem, calc(100% - 2rem));
    padding: 0;
    border: 0;
    border-radius: 0.75rem;
    box-shadow: 0 20px 60px rgb(0 0 0 / 0.2);
  }
  .modal::backdrop { background: rgb(20 20 15 / 0.5); }
  .modal-inner { padding: 1.5rem; }
  .modal-header { display: flex; justify-content: space-between; align-items: start; gap: 1rem; }
  .modal-header h2 { margin: 0; font-size: 1.25rem; }
  .modal-close { background: none; border: 0; padding: 0.25rem; cursor: pointer; color: inherit; }
  .modal-body { margin-top: 1rem; }

  @media (prefers-reduced-motion: no-preference) {
    .modal[open] { animation: modal-in 0.18s ease-out; }
    @keyframes modal-in { from { opacity: 0; transform: translateY(8px); } }
  }
</style>

<script>
  document.querySelectorAll<HTMLButtonElement>('[data-modal-open]').forEach((trigger) => {
    const dialog = document.getElementById(trigger.dataset.modalOpen!) as HTMLDialogElement | null;
    if (!dialog) return;

    trigger.addEventListener('click', () => dialog.showModal());

    // Close when the backdrop (the dialog element itself) is clicked
    dialog.addEventListener('click', (e) => {
      if (e.target === dialog) dialog.close();
    });

    dialog.addEventListener('close', () => trigger.focus());
  });
</script>

Using it

---
import Modal from '../components/Modal.astro';
---
<Modal id="licence-modal" title="What the licence covers" triggerLabel="Licence details">
  <p>Unlimited sites for yourself or paying clients. Reselling the theme files is not allowed.</p>
</Modal>

Why the click-outside check works

The dialog’s content sits inside .modal-inner, which has padding. A click on the backdrop is reported on the <dialog> element itself, while clicks inside land on its children. Comparing e.target === dialog therefore detects backdrop clicks only. Remove that listener if the dialog holds a form people could lose by accident.

Forms inside the dialog

<form method="dialog">
  <button value="cancel">Cancel</button>
  <button value="confirm">Confirm</button>
</form>

Submitting closes the dialog and sets dialog.returnValue to the button’s value, which you can read in the close event.

When not to use a modal

Modals interrupt. For newsletter prompts on content pages, an inline form placed after the article usually works better and doesn’t hurt reading. For cookie choices, a non-modal banner is less disruptive; see the cookie consent banner.

More patterns are in the Astro components guide, and whether a script or island fits is covered in island or script. MDN’s dialog element reference documents the API.

FAQ

Is the dialog element supported in all browsers?

Yes, in all current major browsers, including showModal() and ::backdrop.

Do I need a focus-trap library?

No. showModal() makes content outside the dialog inert, so focus can’t leave it.