Build an Astro Modal Dialog Component
The Problem
Maybe you are fairly new to web development, or maybe you’ve been using a framework or UI library that comes with a modal dialog component, or maybe you cobbled together your own modal dialog component long ago and have been using it ever since… whatever the case, you’ve now reached a crossroad. You’re developing a project in Astro and you need a modal dialog component, but you’re trying to minimize the amount of code you’re going to have to ship to the browser. What should you do?
The Solution
You could reach for a framework or library or existing component, but solving this problem is surprisingly simple in Astro with modern HTML, CSS and a tiny bit of JavaScript. Ready? Let’s do this!
Introducing the <dialog> HTML Element
Sometimes it’s hard to keep up with all the new developments and the continuing evolution of HTML, CSS and Javascript, so it might be surprising to learn that the <dialog> element has had baseline support since March of 2022 already. I know, right? What may be less surprising is that when it comes to modal dialogs, the <dialog> element now does a lot of the heavy lifting for us. Out of the box, <dialog> has the ARIA role="dialog" attribute set and when we invoke it using showModal():
- The
aria-modal="true"attribute is set automatically - The dialog is displayed in the top layer, so no more messing around with z-indexes
- Everything else on the page is rendered inaccessible by way of the inert attribute
- The
<dialog>can be anywhere on the page - The
<dialog>can be dismissed with the Esc key - The
<dialog>can be closed with a<button>in a<form method="dialog">element (no JS needed) - The
::backdroppseudo-element can be styled with CSS
We get all that behavior for free by using the <dialog> element instead of a regular <div>.
OK, enough jibber-jabber… let’s see what a minimal example of the <dialog> element looks like:
<dialog id="dlgAstro"> <p>Hello Astro Community!</p> <form method="dialog"> <button>Close</button> </form></dialog>---import BaseLayout from "../layouts/BaseLayout.astro"import Dialog from "../components/Dialog.astro"---
<BaseLayout> <h1 class="text-4xl font-extrabold">Build an Astro Modal Dialog Component with Tailwind CSS</h1> <button class="bg-indigo-600 px-4 py-2 rounded-sm text-white shadow-lg" id="btnShowModal" type="button"> Show Modal </button> <Dialog /></BaseLayout>
<script> const button = document.getElementById("btnShowModal") as HTMLButtonElement const dialog = document.getElementById("dlgAstro") as HTMLDialogElement button.addEventListener("click", () => { dialog.showModal() })</script>
A few things to note in the above example:
- The only JS we need to make this work is a click listener on the
<button>to callshowModal(). - We don’t get any styling for free, so our
<dialog>is a bit of an ugly-duckling. - We also don’t have much styling on the
::backdroppseudo-element, so the undelying page is not obscured in any meaningful way.
Let’s fix the latter point with Tailwind CSS and add some Astro props to make the dialog more appealing, useful and reusable.
---import type { HTMLAttributes } from "astro/types"import { Icon } from "astro-icon/components"
export interface Props extends HTMLAttributes<"dialog"> { title: string}const { title, class: classList, ...attrs } = Astro.props---
<dialog class:list={[ "w-[90dvw] bg-transparent backdrop:bg-neutral-950/70 backdrop:backdrop-blur-xs md:w-[75dvw] lg:w-[50dvw]", classList ]} {...attrs} aria-label={title}> <form class="grid grid-cols-1 grid-rows-[auto_1fr] divide-y divide-neutral-300 rounded-lg border border-neutral-300 bg-transparent dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-900" method="dialog"> <header class="flex items-center justify-between rounded-t-lg bg-neutral-100 px-4 py-3 dark:bg-neutral-950"> <h3 class="text-2xl font-medium">{title}</h3> <button class="rounded-full p-2 text-neutral-600 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 dark:focus-visible:outline-primary-500" autofocus> <span class="sr-only">Close</span> <Icon class="size-6" aria-hidden="true" name="mdi:close" /> </button> </header> <div class="max-h-[70dvh] bg-neutral-50 p-4 dark:bg-neutral-900 rounded-b-lg"> <slot /> </div> </form></dialog>Wow! There’s a lot going on there, so let’s break it all down. Starting with the props declaration:
export interface Props extends HTMLAttributes<"dialog"> { title: string}const { title, class: classList, ...attrs } = Astro.propsIf you’re not familar with this way of declaring props, it might look a bit intimidating; but don’t worry, it’s pretty straight forward. With this interface Props extends HTMLAttributes<"dialog"> code, we’re declaring that our <Dialog /> component will have all the same attributes as the regular HTMLDialogElement. We then add a required title prop. Lastly, we deconstruct Astro.props into title and classList (since “class” is a reserved word) and all the other props are captured by ...attrs.
With our props interface defined, we can use our <Dialog /> component like so:
<Dialog id="dlgAstro" title="Hello Astro Community!"> Amet laboris nisi cillum adipisicing irure anim enim aute velit ...</Dialog>where we’re passing in a title and an id. Eventhough we didn’t explicitly declare id in the interface, it will be captured with ...attrs, as mentioned above. Anything we include in between our <Dialog></Dialog> tags, will be rendered in our component’s <slot />. Let’s have a look at that next:
<dialog class:list={[ "w-[90dvw] bg-transparent backdrop:bg-neutral-950/30 backdrop:backdrop-blur-xs md:w-[75dvw] lg:w-[50dvw]", classList ]} {...attrs} aria-label={title}> ... <h3 class="text-2xl font-medium">{title}</h3> ... <slot /> ...</dialog>In the above simplified code snippet, we can see that the title prop is used to set the aria-label attribute of the <dialog> as well as the <h3> heading content. We’re also using the spread-operator with {...attrs} to pass on all the other props, like id, to the <dialog> element. Our custom classList is added to the class:list array, which allows us to pass in other classes to the <dialog> element. Lastly, we have a <slot /> element, which is used to render the content of our <Dialog /> component.
To give the dialog a more familair look, we create a <header> element that includes a styled <h3> element, as well as a “close” button. For the “close” button, we include a <span> element that has the sr-only class (screen readers only), for accessibility, and we’re using the astro-icon component to render a “close” SVG icon.
<header class="flex items-center justify-between rounded-t-lg bg-neutral-100 px-4 py-3 dark:bg-neutral-950"> <h3 class="text-2xl font-medium">{title}</h3> <button class="rounded-full p-2 text-neutral-600 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 dark:focus-visible:outline-primary-500" autofocus> <span class="sr-only">Close</span> <Icon class="size-6" aria-hidden="true" name="mdi:close" /> </button></header>On the Tailwind CSS side, I won’t go through all the classes that are used in the above examples, but there are a couple that are worthy of further explanation. There are two classes, backdrop:bg-neutral-950/70 and backdrop:backdrop-blur-xs, that are used to style the <dialog> ::backdrop pseudo-element. In Tailwind CSS, we can style the ::backdrop pseudo-element using the backdrop: modifier. As you may have already guessed, the first one is used to set the backdrop’s background color to a very dark gray with an opacity of 70% and the second one is used to set a backdrop-filter with a slight blur.
With all that in place, let’s have a look at what our dialog looks like:
Yeah, that’s much better, right? The backdrop has a darker semi-transparent background with a slight blur-sm to focus the viewer’s attention on the dialog.
To take it a little bit further, we could add a footer with a couple of buttons, like Yes and No, or OK and Cancel, or whatever is needed.
...<footer class="flex items-center justify-end gap-2 rounded-b-lg bg-neutral-100 px-4 py-3 dark:bg-neutral-950"> <button class="rounded-sm border-2 border-neutral-600 bg-neutral-600 px-4 py-2 text-white hover:border-neutral-700 hover:bg-neutral-700 hover:shadow-lg" value="Yes" >Yes</button > <button class="rounded-sm border-2 border-neutral-600 bg-transparent px-4 py-2 text-neutral-600 hover:border-neutral-700 hover:text-neutral-700 hover:shadow-lg" value="No" >No</button ></footer>...
Since all the buttons are inside a <form method="dialog">, and they have a default type of submit, they will all close the dialog without the need of any JS. Of course, we could also use a <button type="button"> instead and handle the click event and closing of the dialog ourselves. If we wanted to know which button was used to close the dialog, we can set the value attribute of the buttons, and listen for the “close” event on the dialog.
dialog.addEventListener("close", (event) => { div.innerText = `Modal Result: ${event.target.returnValue} button`})We could stop there, but we can do a little bit more to make it “feel” better. Here’s what I mean. When invoking showModal(), the <dialog> instantly appears on the screen, and when we close it, it instantly disappears. Wouldn’t it “feel” better if the dialog transitions were animated? Having them fade in and out smoothly, rather than just appearing and disappearing abruptly? Let’s do that and add some polish to our shiny new <Dialog /> component.
<dialog class:list={[ "w-[90dvw] md:w-[75dvw] lg:w-[50dvw]", "m-auto overscroll-contain bg-transparent", "transition-[display,opacity,overlay,scale] transition-discrete duration-300 ease-out", "opacity-0 scale-0 open:opacity-100 open:scale-100 starting:open:opacity-0 starting:open:scale-0", "backdrop:transition-[display,opacity,overlay] backdrop:transition-discrete backdrop:duration-300 backdrop:ease-out", "backdrop:bg-rich-black/30 backdrop:opacity-0 backdrop:backdrop-blur-xs open:backdrop:opacity-100 starting:open:backdrop:opacity-0", classList ]} ... > ...</dialog>Although that may look like a lot of Tailwind classes, it’s only doing two things. It’s transitioning the scale and opacity of the <dialog> and ::backdrop from 0 to 1 when the [open] attribute is set and back again when it’s removed (on close). That’s all. However, there are a few things in there that may not be familar to everyone. Until recently, there was no easy way to animate an element that was starting from display: none, like a <dialog>, because the browser had no initial values to transition from. The @starting-style CSS at-rule was designed to address that very issue. As the name implies, it allows you to specify what the starting styles of the selected elements should be when transitioning from display: none to say display: block. Another relatively new item is the transition-behavior property, which needs to be set to allow-discrete on both display and overlay. With starting styles in place, you can now animate the dialog and backdrop to transition in and out smoothly. Unfortunately, @starting-style is not yet supported in FireFox, so FireFox users will not see the animation, but the <Dialog /> component will still work.
I like using the class:list to separate the classes into different lines, so I can keep related items together. I find it easier to edit and maintain the code that way. Let’s breakdown the classes that are used to achieve the animation:
- line 1: sets the width of the dialog at different breakpoints
- line 2: centers the dialog and prevents scroll chaining when the dialog is open
- a
<dialog>will be centered by default, but since Tailwind resets the margin to 0, we need to set it back toautoto center it again overscroll-containprevents scroll chaining- set
bg-transparentto remove the default background color of the dialog, so that we can style it ourselves
- a
- line 3: sets the transition properties for the dialog
- line 4: sets the initial styles for the dialog when transitioning from
display: nonetodisplay: blockand back again - line 5: sets the transition properties for the backdrop
- line 6: sets the initial styles for the backdrop when transitioning from
display: nonetodisplay: blockand back again
That’s it! If you’d like to have a look at the full code, I’ve set up a StackBlitz for you to play around with and experiment. Enjoy!
Bonus: Light Dismiss
You may have noticed that you can’t close a modal by clicking on the backdrop, typically referred to as a light dismiss. That’s the expected behavior of a modal dialog. However, we can easily override that behavior with an event listener (see below). But if you’re overriding the default behavior, then that’s a strong indicator that you may be using the wrong element. Instead of <dialog>, you may want to have a look at using popover instead.
--- ...---
<dialog id="myDialog"> <!-- ... --></dialog>
<script> const myDialog = document.getElementById("myDialog") as HTMLDialogElement; // light dismiss myDialog.addEventListener("click", ({ target }) => { if (target.nodeName === 'DIALOG') { target.close('dismiss') } })</script>To handle the light dismiss, we simply add a “click” event handler to our <dialog> element. We then deconstruct the event object to extract the target property. If target.nodeName is “DIALOG”, then we know that the backdrop was clicked, and we can close the dialog. We can pass an optional string value to the close() method to indicate how the dialog was closed.
Summary
In this article, we explored how to build an Astro Modal Dialog Component with Tailwind CSS and a tiny bit of JavaScript. We learned that the <dialog> element provides a lot of built-in functionality that we used to have to code ourselves. We also learned that some recent additions to CSS allow us to animate the dialog and its backdrop, so that they transition in and out smoothly. You can use the lessons learned here to build your own Astro Modal Dialog Component that can be used repeatedly in your future projects.
Resources
If you’d like to dive deeper into the <dialog> element, I encourage you to check out these resources:
- HTMLDialogElement (MDN)
- <dialog>: The Dialog element (MDN)
- Pop n’ Lock Dialog Mini Web Machine (YouTube video by Adam Argyle)
- dialog = the easiest way to make a popup modal (YouTube video by Kevin Powell)
- Styling modals just got easier! (YouTube video by Kevin Powell)