📝 The Native HTML Dialog
We've all heard the buzz around browser native dialogs which are now widely supported across all modern browsers. Now you no longer need to depend on third party packages to achieve a user friendly dialog/popover experience.
Now if you are just here for the code, here it is below. However, if you are here to understand how we got here, stick to the end!
<script setup lang="ts">
import { ref } from 'vue';
const dialog = ref<HTMLDialogElement>();
const props = defineProps({
classes: {
type: String,
default: "",
},
});
const visible = ref(false);
const showModal = () => {
dialog.value?.showModal();
visible.value = true;
};
defineExpose({
show: showModal,
close: (returnVal?: string): void => dialog.value?.close(returnVal),
visible,
});
</script>
<template>
<dialog
ref="dialog"
@close="visible = false"
>
<form
v-if="visible"
method="dialog"
:class="{
[props.classes]: props.classes,
}"
>
<slot />
</form>
</dialog>
</template>
👷 The Breakdown
The new Dialog API is pretty straightforward. As seen from the MDN Page, all you need to get started is a simple <dialog>
element. Any reference to this element will have programatic access to the function .showModal()
to toggle the dialog, so you could do this with a button!.
Vanilla HTML
Lets take a look at the provided example
<dialog open>
<p>Greetings, one and all!</p>
<form method="dialog">
<button>OK</button>
</form>
</dialog>
Do note the additional comment on MDN,
Because this dialog was opened via the open attribute, it is non-modal. In this example, when the dialog is dismissed, no method is provided to re-open it. Opening dialogs via HTMLDialogElement.show() is preferred over the toggling of the boolean open attribute.
So although this is a good demonstration of the result, programatic access is suggested.
Looking closer, other than the open attribute, we have
- A form component, with
method="dialog"
. This is a new form method, which on form submission, automatically closes the dialog with a submit event. - Accessibility, pressing tab will toggle only through the form elements in the dialog.
- The dialog content will be shown in a new layer, which can be visualized from the browsers dev tools.
🤖 Difference between show and showModal
There are two ways to open the dialog.
-
.show()
will toggle open the dialog, but- Esc will not close the element.
- Backdrop will not be darkened (default styling).
-
.showModal()
will toggle open the dialog in the modal mode,- Esc will close the modal
- Backdrop will be darkened in default styling. (The styling can be changed).
⛵️ Bringing in Vue
Lets be real, you are probably not working on a project with vanilla HTML, it is nice but does not scale. You might be using a Frontend library, probably something among React, Vue or Svelte. I will be covering Vue here, but the design pattern should be similar in Svelte. Pretty sure, React folk don't have to worry about not using Third Party packages :P.
A simple solution in Vue is to just slap a <slot />
, have a two way binding to the open
attribute via props and call it a day. Lets look at this example
<script setup lang="ts">
const props = defineProps<{
open: boolean
}>();
</script>
<template>
<dialog :open="props.open">
<form method="dialog">
<slot />
</form>
</dialog>
</template>
This is a good enough solution but lets see if we can do better!
💪 Trying to do better
We will try to
- Adhere to using programmatic access.
- See if we can mount the child component only when the modal is open, so that any mount hooks run only when needed.
To achieve the first we shall use a ref
. This way we get a ref to the dialog element, to be able to call the showModal
function. We avoid ids, because we want this re-usable! Plus avoid document.querySelector
makes it SSR safe?. Don't quote me on that.
<script setup lang="ts">
import { ref } from 'vue';
const dialog = ref<HTMLDialogElement>();
....
</script>
<template>
<dialog
ref="dialog"
>
...
</dialog>
</template>
We then declare a function to call showModal(), then expose that function to parent components via defineExpose()
.
const showModal = () => {
dialog.value?.showModal();
};
defineExpose({
show: showModal
});
The parent component can then bind a ref to the modal and toggle dialog as needed.
import Modal from 'path/to/Modal.vue';
const modal = ref<InstanceType<typeof Modal>>();
const showModal = () => modal.value?.show();
With that complete lets also get conditional loading so that mount hooks work proper (otherwise this is equivalent to a v-show
)
We create a state called visible and toggle that when state of dialog changes. We shall set visible to true, when we show the modal, and use the close event to toggle it to false.
<script setup lang="ts">
...
const visible = ref(false);
const showModal = () => {
dialog.value?.showModal();
visible.value = true;
};
...
</script>
<template>
<dialog
ref="dialog"
@close="visible = false"
>
<form
v-if="visible"
method="dialog"
>
<slot />
</form>
</dialog>
</template>
and there's that! Note that if you still want a cleaner approach, you can still use the open
prop for conditional rendering as well as the open
attribute but do note that some of the accessibility will be lost.
<script setup lang="ts">
const props = defineProps<{
open: boolean
}>();
</script>
<template>
<dialog :open="props.open">
<form v-if="props.open" method="dialog">
<slot />
</form>
</dialog>
</template>
the final result is the code at the very start of the guide!
🎨 With DaisyUI and TailwindCSS!
DaisyUI v3 supports native modals! Here is a quick custom component with slots and styles to make life easier for you!
<script setup lang="ts">
import { ref } from 'vue';
const dialog = ref<HTMLDialogElement>();
const props = defineProps({
confirmText: {
type: String,
default: 'Confirm',
},
cancelText: {
type: String,
default: 'Cancel',
},
hideConfirm: {
type: Boolean,
default: false,
},
showCancel: {
type: Boolean,
default: false,
},
classes: {
type: String,
default: '',
},
});
const emit = defineEmits(['confirm', 'cancel']);
const cancel = () => {
dialog.value?.close();
emit('cancel');
};
const confirm = () => {
dialog.value?.close();
emit('confirm');
};
const visible = ref(false);
const showModal = () => {
dialog.value?.showModal();
visible.value = true;
};
defineExpose({
show: showModal,
close: (returnVal?: string): void => dialog.value?.close(returnVal),
visible,
});
</script>
<template>
<dialog
ref="dialog"
class="modal modal-bottom sm:modal-middle"
@close="visible = false"
>
<form
v-if="visible"
method="dialog"
:class="{
'modal-box rounded-none p-4': true,
[props.classes]: props.classes,
}"
>
<slot />
<div class="modal-action" v-if="!props.hideConfirm || props.showCancel">
<slot name="footer" />
<slot name="actionButtons">
<button
v-if="props.showCancel"
value="false"
class="btn"
@click.prevent="cancel"
>
{{ props.cancelText }}
</button>
<button
v-if="!props.hideConfirm"
value="true"
class="btn btn-primary"
@click.prevent="confirm"
>
{{ props.confirmText }}
</button>
</slot>
</div>
</form>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</template>
Here is a stackblitz for the same
Closing
Thanks so much for reading! If you have any improvements on the above do share in the comments. As for me, I really love the new native dialog, and the future things coming to vanilla HTML, like the Popover element bring simpler tooltips and page transitions which may enable frameworks like astro to simplify page transitions via CSS where JS libraries like SWUP which takeover client side page navigation are the only way to transition.
Top comments (3)
Thank you for the wonderful article. It helped me.
I tried the same thing in Nuxt 3,the modal opens up but clicking outside doesn't close the modal. Do you have any idea on how to fix this?
Sorry for such a late response! But It is also possible to do this natively, with some CSS magic (inspired by daisyui daisyui.com/components/modal/#dial...)
Here is the updated stackblitz stackblitz.com/edit/vitejs-vite-ih...
edit: Even if you are not using DaisyUI you should be able to replicate the css.
Hi there, there is a nice directive in VueUse that you can use for that:
import { vOnClickOutside } from '@vueuse/components'
and then use
dialog v-on-click-outside="close"
FYI: It is not part of the @vueuse/nuxt module, you need to install it (npm i --savedev @vueuse/components) and then import it as it will not be autoimported by nuxt.