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-labelledbypointing 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.