Published: November 25, 2025
Elements and windows that pop up on the screen are one of the most common patterns on the web. With use cases spanning from alerts and brief forms requesting data, to the now ubiquitous cookie settings prompt, these layered UI patterns are used frequently by developers.
Most of these user interface elements are added to web applications using either custom JavaScript or common libraries. With that route there's a lot to make sure that you or the library you choose gets right.
The <dialog> element and the popover attribute are two Baseline layered UI patterns that developers can reach for instead of custom implementations. To show the advantages of using layered UI patterns built into today's web browsers—and to give an example of when you might reach for <dialog> or use the popover attribute—this article walks through an example of a modal that appears when the user attempts to save an image to a favorites list without being logged in.
A modal <dialog>
The <dialog> element is a box that appears over other content that a user can interact with to complete a task or input information. They can be modal or non-modal. The main difference is that when a modal <dialog> is open, interaction with the rest of the content underneath is disabled.
Each image in the gallery demo has a "favorite" button that can be pressed to save the image to what would be a favorites list. Since the user is not logged in, a pop up prompts them to sign up with an email address in order to complete the action.
The modal in this example is a <dialog> element inserted right after the image gallery.
<dialog aria-labelledby="dialog-title" aria-describedby="description">
<h2 id="dialog-title">Yay! You found a favorite!</h2>
<p id="description">Sign up with your email address to start adding to your favorites list.</p>
</dialog>
Use the showModal() method to open a modal <dialog>
The <dialog> element is hidden by default. To display it as a modal, you can use the showModal() method.
const openDialogBtns = document.querySelectorAll(`[data-button*="show-dialog"]`);
const dialog = document.querySelector('dialog');
openDialogBtns.forEach(openDialogBtn => {
openDialogBtn.addEventListener('click', () => {
dialog.showModal();
});
});
In addition to opening the <dialog> as a modal, using the showModal() method:
- Adds the
dialogto the top layer. - Attaches a
::backdroppseudo-element directly underneath it. - Implicitly adds the
aria-modal="true"attribute. - Automatically makes all of the elements on the page underneath the
dialoginert.
In a custom implementation, all of the behavior included with dialog.showModal or command="show-modal" are all details you would have to account for yourself. In addition to these characteristics of a modal, the WAI ARIA Authoring Practices Guide lists further requirements and guidelines for the ideal user experience once a modal dialog is open:
- Focus moves to an element inside of the dialog.
- Tabbing and keyboard focus should stay within the dialog until it is closed.
- Since users shouldn't be able to interact with the content underneath the modal, that content should be slightly dimmed or obscured.
- Once a modal is closed, focus should return to the element that opened it.
That's a lot to remember, test, and get right.
However, with the HTML <dialog> element, its built-in showModal() method, and the ::backdrop CSS pseudo-selector, you get all of the web features just described that are now Baseline.
How to close the dialog
Modal dialogs can be closed by pressing <kbd>Esc</kbd>, or you can add a <button> element to explicitly close the them with the built-in HTMLDialogElement.close() method.
// Code omitted...
const dialog = document.querySelector('dialog');
const closeDialogBtn = document.getElementById('close');
// Code omitted...
closeDialogBtn.addEventListener('click', () => {
dialog.close();
});
You can also declaratively close a dialog by adding a <form> element separate from any user inputs in the dialog, and adding method="dialog". This sets the submission behavior for all submit events in that form to close the dialog. Adding markup that closes the dialog declaratively may feel familiar to you if you've used component libraries with a <DialogClose> component.
<dialog aria-labelledby="dialog-title" aria-describedby="description">
<form method="dialog">
<button data-button="close" type="submit">Close</button>
</form>
</dialog>
popover, rising to the top
The popover attribute is another way to add layered UI patterns to your applications. Unlike the <dialog> element, any element can be turned into a popover. The two are similar but have some key differences:
- Popovers don't make the rest of the page inert, while a modal dialog does.
- Default popovers (
popover="auto") can be "light-dismissed", while modal dialogs opened withdialog.showModalcan only be light-dismissed if the dialog has theclosedBy="any"attribute . - Popovers have no inherent semantics, while a dialog has an implicit ARIA role of
"dialog". - Popover is styled with the
:popover-openpseudo class, while a dialog is styled with theopenattribute
One behavior that a <dialog> and an element with the popover attribute shares is that when they are open their content is automatically promoted to the top layer. As the name implies, elements in the top layer are rendered outside of the document flow, on top of all other content. Even if content in the normal document flow has a sky-high z-index, it will always appear underneath content in the top layer.
What happens when you open an element that gets promoted to the top layer from inside of another element that's already open in the top layer? The last element opened inside of the top layer is highest in the top layer stack, so the popover in the demo opens on top of the dialog without you having to configure a z-index position for it.
<form method="dialog">
<label for="email">Enter your email</label>
<button id="popover-trigger" type="button" popovertarget="privacy-popover">
<span class="visually-hidden">How we handle your email</span>
</button>
<p id="privacy-popover" popover>As with all of your information, we promise not to sell your email address</p>
</form>
The button that will open the popover is placed next to the label for the email address input, and is made into a control for opening our popover by adding the popovers id to the value of its popovertarget attribute. By giving the popover the attribute of popover and assigning it an id that matches the popovertarget attribute on the popover control, the popover can now be toggled.
Autofocus
The <dialog> traps focus inside of itself and when opened, and places focus on the first focusable element inside of it. Use the autofocus attribute if it makes more sense to apply focus on a different element instead. In the demo, the <input> element for the email address has an autofocus attribute, so that users can start entering their email address as soon as the dialog is open.
Style the ::backdrop
As mentioned earlier, a <dialog> opened by the showModal() method is displayed in the top layer, and makes all content underneath it inert. You can style the attached ::backdrop pseudo-element to further emphasize the <dialog>, and adjust the visibility of content underneath.
The ::backdrop has a faint linear-gradient and a blur effect when the dialog is open. The backdrop-filter property lets you apply graphic effects to the area behind an element.
To achieve the animation effect on the color stops in the linear-gradient, you first must register the custom properties with the @property rule. Registering the custom property in this way—as opposed to using a standard custom property—lets the browser know what data type to expect, allowing for smooth transitions and animations. You can then define @keyframes, or use a transition to specify when and how the color stops change.
@property --backdrop-gradient-start {
syntax: "<color>";
initial-value: oklch(33.894% 0.08072 246.33);
inherits: true;
}
@property --backdrop-gradient-end {
syntax: "<color>";
initial-value: oklch(45.859% 0.00345 174.48 / 0.3);
inherits: true;
}
/* ... */
dialog[open]::backdrop {
opacity: 1;
background: linear-gradient(140deg,
var(--backdrop-gradient-start),
var(--backdrop-gradient-end));
backdrop-filter: blur(2px);
animation: show-gradient var(--transition-timing-slower) forwards;
}
@keyframes show-gradient {
from {
--backdrop-gradient-start: oklch(45.859% 0.00345 174.48 / 0.3);
--backdrop-gradient-end: oklch(33.894% 0.08072 246.33 / 0.3);
}
to {
--backdrop-gradient-start: oklch(33.894% 0.08072 246.33);
--backdrop-gradient-end: oklch(45.859% 0.00345 174.48 / 0.3);
}
}
You can use a different technique to animate the dialog and popover and you'll see how that works in the next section.
Make an entrance (and exit) with @starting-style
The <dialog> element and popover both go from being display: none to display: block by default. Due to how CSS transitions typically handle this flip of the display property, elements having their display set to any visible display property from display: none enter and exit the page immediately. Thankfully, with a combination of Baseline features @starting-style and transition-behavior you can use transitions to make their entrances and exits more seamless.
In the next step, you'll animate the dialog. The demo transitions the dialog's opacity, translate, overlay, and display properties. You can achieve this by setting values for the dialog's three states.
The open state describes how the dialog should look once it is open:
dialog[open] {
--opacity: 1;
--translate: 0 0;
}
The state in between, the transitioning state, is made by adding a @starting-style block. These are the styles the browser will use when the dialog is first rendered in the DOM, as it transitions from display: none to display: block. Without the @starting-style block, the transition would fail, since the element wouldn't have a previous state from which to transition.
/* Open state */
dialog[open] {
--opacity: 1;
--translate: 0 0;
/* Transitioning state */
@starting-style {
--opacity: 0;
--translate: 100vw -10rem;
}
}
The last state to include to successfully transition elements like dialog is the closed state. This is the default or exit state that the element will return to as it transitions to display: none.
Here you can include the specific transition settings for each property you are animating. In addition to the opacity, translate, and display properties you must also transition the overlay property for animations to work.
/* Closed state */
dialog {
opacity: var(--opacity, 0);
translate: var(--translate, 100vw -10rem);
transition:
opacity 1s ease-in,
translate 0.6s ease-in-out
overlay 0.6s ease-in-out,
display 0.6s linear;
transition-behavior: allow-discrete;
}
The final step is to specify the transition-behavior for the overlay and display properties that normally have discrete animation behavior.
Conclusion
Reaching for the web platform's tools can make it easier to implement layered UI patterns accessibly, and can keep you from worrying about handling all of the expected functionality yourself. This gives you more time to focus on enhancing the user experience with platform features, and less time debugging custom or library implementations.