First let’s make a barebones Svelte component which renders a dialog element with a slot. We’ll export a dialog
property which refers to that dialog element.
Dialog.svelte
<script lang="ts">
export let dialog: HTMLDialogElement;
</script>
<dialog bind:this={dialog}>
<slot />
</dialog>
Then in our page we can import that component and use it to render a dialog with some content. We’ll bind that dialog
property to a variable in our page so we can open and close the dialog.
page.svelte
<script lang="ts">
import Dialog from './Dialog.svelte';
let dialog: HTMLDialogElement;
</script>
<Dialog bind:dialog>
<h1>Hi there!</h1>
<button on:click={() => dialog.close()}>Close</button>
</Dialog>
<button on:click={() => dialog.showModal()}>Open the modal</button>
Try it out!
What you get for free
The native dialog element does a lot for us out of the box (or in it, I suppose).
- The dialog has some basic styling: a white background, a black border, and a backdrop which darkens the rest of the page.
- The dialog is automatically positioned in the center of the viewport.
- If the content of the dialog is longer than the viewport, it will scroll internally, with a slight inset from the top and bottom of the viewport.
- It traps focus while it is open, allowing you to tab through elements in the dialog. You can tab out of the dialog to focus other elements of the browser window, but other content in your page will not be focusable until the dialog is closed.
- Pressing the escape key automatically closes the dialog.
- You can even open a dialog from within another dialog, and the browser will keep track of which dialog is currently open. Pressing escape will only close one dialog at a time.
- Probably some other built-in niceties that I haven’t noticed yet.
Taking it up a notch
We get so much functionality with the dialog element that we almost don’t need to make a separate component. But having done so, we can now add some custom styling and other features to our Dialog component.
Some ideas to try:
- Style the border, background, and backdrop of the dialog
- Set a reasonable max-width
- Add an optional title bar with a close button
- Animate the modal opening and closing
- Prevent scrolling the page content while the modal is open
Here’s a version of the Dialog component with some of these features added.
StyledDialog.svelte
<script lang="ts">
import { onMount } from 'svelte';
export let title: string | undefined = undefined;
export let element: HTMLDialogElement;
// Export a custom open function that calls the dialog element's showModal method.
export function open() {
element.showModal();
}
// Export a custom close function that sets a closing attribute on the dialog
// element. This attribute is used to trigger the closing animation. So we add
// a listener for animationend which calls the afterClosing method below. By
// passing once: true, we don't have to remove this event listener.
export function close() {
element.addEventListener('animationend', afterClosing, { once: true });
element.setAttribute('closing', '');
}
// When the closing animation completes, we can remove the closing attribute
// and call the dialog's native close method.
function afterClosing() {
element.removeAttribute('closing');
element.close();
}
// When the user presses the escape key, the browser calls the dialog's close
// method directly, bypassing our nice closing animation. So we can listen for
// the 'cancel' event, prevent its default behavior, and call our custom close
// method instead.
function onCancel(event: Event) {
event.preventDefault();
close();
}
// When the component mounts, add our event listener, and remove it on dismount.
onMount(() => {
element.addEventListener('cancel', onCancel);
return () => {
element.removeEventListener('cancel', onCancel);
};
});
</script>
<dialog bind:this={element}>
{#if title}
<div class="modal-title">
<h2>{title}</h2>
<button class="close-button" on:click={close}>✗</button>
</div>
{/if}
<div class="modal-content">
<slot />
</div>
</dialog>
<style lang="less">
// just using less for nesting syntax
dialog {
overscroll-behavior: contain;
border: 1px solid rgba(0 0 0 / 0.3);
border-radius: 0.5rem;
box-shadow: 0 4px 10px rgba(0 0 0 / 0.3);
max-width: min(95vw, 600px);
padding: 0;
// animate when the dialog opens
&:is([open]) {
animation: fade-in 0.2s ease-out, slide-in 0.2s ease-out;
&::backdrop {
animation: fade-in 0.2s ease-out;
}
}
// animate when the dialog is closing
&:is([closing]) {
animation: fade-out 0.2s ease-out, slide-out 0.2s ease-out;
&::backdrop {
animation: fade-out 0.2s ease-out;
}
}
// add a gradient and blur filter to the backdrop pseudo-element
&::backdrop {
background-image: linear-gradient(45deg, hsla(0 50% 50% / 0.5), hsla(200 50% 50%/ 0.5));
backdrop-filter: blur(4px);
}
.modal-title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(0 0 0 / 0.1);
font-size: 24px;
position: sticky;
top: 0;
background-color: white;
h2 {
margin: 0;
}
.close-button {
appearance: none;
border: none;
background: none;
border: none;
box-shadow: none;
font-size: 1.25rem;
border-radius: 4px;
padding: 0;
width: 2em;
text-align: center;
aspect-ratio: 1;
}
}
.modal-content {
padding: 1rem;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
@keyframes slide-out {
from {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
</style>
Using the styled dialog component in a page
page.svelte
<script lang="ts">
import StyledDialog from './StyledDialog.svelte';
// Our local dialog variable is now an instance of the custom
// component instead of an HTMLDialogElement
let dialog: StyledDialog;
</script>
<!-- Using bind:this to bind the component instance to our local dialog variable -->
<StyledDialog bind:this={dialog} title="Hi there!">
<p>Nulla amet anim laboris enim aute. Anim laboris...</p>
</StyledDialog>
<!-- Now we can call the exported open method on the dialog instance -->
<button on:click={() => dialog.open()}>Open the dialog</button>
Try it out
Hi there!
A long styled modal
Much of the information and ideas in this post came from these two YouTube videos by CSS guru Kevin Powell:
And be sure to check out the MDN docs for HTMLDialogElement.
Addendum: this post was updated on November 18th, 2023 to use bind:this
so that we can access the exported open/close methods on the component instance.