A Journey to Craft Interactive UI Experiences with Dialogs
As far as I remember, this challenge has been the most persistent one since I began my journey in web development. I recently uncovered a method to build a component library, which was a long endeavor. However, solving the intricacies of dialog and modal management has been even more extensive, complex, and crucial due to its ubiquitous presence in every application.
Today, I'm thrilled to share the solution I found, I didn't develop it, for managing dialogs and modals effectively within your applications. This resolution stems from years of experience, and I hope it benefits you as it has benefited me.
Understanding the Issue
Before delving into the problem, let's align on what we're discussing. This article focuses on dialogs, modals, and slideovers. Each serves to display information or solicit confirmation, differing mainly in presentation and usage.
Try to open the dialog by clicking the button below.
Similarly, you can open a slideover by clicking the button.
The design and dialog types might seem secondary; the important aspect lies in the underlying code. Let's examine it:
<template>
<Modal
title="Dialog"
description="This is a dialog"
>
<Button label="Open Dialog" />
</Modal>
</template>
<template>
<Slideover
title="Slideover"
description="This is a slideover"
>
<Button label="Open Slideover" />
</Slideover>
</template>
They are quite similar, relying on the same component beneath the surface. The true intrigue is in how this code is utilized. These examples illustrate that dialogs integrate seamlessly as any HTML element, which is the crux of the problem.
Dialogs are a special type of component in an application:
They can be invoked from anywhere within the application.
They open through various methods, such as:
- Button clicks
- Key presses
- Route navigations
The ubiquitous nature makes dialog-free applications nearly impractical without sacrificing user experience:
- Creating items
- Editing items
- Action confirmations
- Item visualizations
- Etc.
Each action might lead to immediate consequences or await a server response, complicating dialog usage possibilities.
Due to these factors, dialogs demand meticulous care in development and usage; otherwise, they and their logic can quickly become chaotic.
Additionally, it has always been a personal preference of mine to enable dialog stacking with smooth animations, like this:
Inline Dialogs
An initial attempt to address this challenge is via inline modals. It's a prevalent approach and suits simple cases. The strategy involves incorporating a modal component directly within the template that requires it, as demonstrated earlier.
<template>
<Modal
title="Dialog"
description="This is a dialog"
>
<Button label="Open Dialog" />
</Modal>
</template>
Despite the straightforward implementation, this approach presents at least two significant drawbacks:
- Dialog components become tightly coupled with the utilizing component, complicating comprehension and refactoring.
- Overhead arises from boilerplate code managing dialog states, especially when opening dialogs without buttons or decoupling templates from dialogs. An example of opening a dialog using a key press follows:
<script lang="ts" setup>
import { ref } from 'vue'
const isDialogOpen = ref(false)
defineShortcuts({
meta_k: () => {
isDialogOpen.value = !isDialogOpen.value
}
})
</script>
<template>
<div>
<Modal
v-model:open="isDialogOpen"
title="Dialog"
description="This is a dialog"
/>
</div>
</template>
This method quickly becomes cumbersome and tough to maintain over time. Multiple dialogs intensify the boilerplate, requiring repeated additions in each component utilizing the dialog.
Global Dialogs
Enhancing dialog management involves programmatically opening dialogs universally, decoupling their invocation from any direct component relationship. This pattern facilitates code refactoring by separating dialogs physically from components, enhancing reusability across multiple components without redundant incorporations.
An App.vue
file might look like this:
<template>
<div>
<Layout>
<RouterView />
</Layout>
<ModalFeature1 />
<ModalFeature2 />
<ModalFeature3 />
<!-- And so on... -->
</div>
</template>
Achieving this setup mandates sharing a variable between the dialog-opening component and the dialog itself. In Vue, a straightforward--though not ideal--approach is to create a composable externalizing its state.
import { ref } from 'vue'
const isOpen = ref(false)
export function useDialog() {
function open() {
isOpen.value = true
}
function close() {
isOpen.value = false
}
return {
isOpen,
open,
close
}
}
Utilize this composable for both the dialog-opening component and the dialog itself.
<script lang="ts" setup>
import { useDialog } from '@/composables/useDialog'
const { open } = useDialog()
</script>
<template>
<Button label="Open Dialog" @click="open" />
</template>
<script lang="ts" setup>
import { useDialog } from '@/composables/useDialog'
const { isOpen, close } = useDialog()
</script>
<template>
<Modal
v-model:open="isOpen"
title="Dialog"
description="This is a dialog"
>
<Button label="Close" @click="close" />
</Modal>
</template>
This approach is certainly superior to the inline method but has drawbacks:
Inclusion of modals at the app (or route) top-level creates potential code redundancy.
Dialog management becomes elaborate, particularly with numerous dialogs or props, each requiring passage through the composable.
tsimport { ref } from 'vue' const isOpen = ref(false) const title = ref('') const description = ref('') export function useDialog() { function open(newTitle: string, newDescription: string) { title.value = newTitle description.value = newDescription isOpen.value = true } function close() { isOpen.value = false } return { isOpen, title, description, open, close } }
Note
Simplify composable writing with createSharedComposable
from @vueuse/core
to craft a singleton composable, automatically sharing the same state between all utilizing components.
Programmatic Dialogs
The latest, profoundly effective approach I discovered involves programmatically managing dialogs. This method resolves the difficulties mentioned earlier by allowing complete dialog control through code rather than templates.
Having used programmatic dialogs in Angular with Angular Material, I appreciated its capacity for code simplification.
const dialogRef = dialog.open(UserProfileComponent, {
height: '400px',
width: '600px',
})
dialogRef.afterClosed().subscribe((result) => {
console.log(`Dialog result: ${result}`) // Pizza!
})
dialogRef.close('Pizza!')
For a long time, I missed such a feature in Vue.
Until recently, when Nuxt released version 3 of Nuxt UI with the useOverlay
composable. This shared application composable enables dialog opening from any location, mirroring Angular Material functionality.
<script setup lang="ts">
const overlay = useOverlay()
async function openModal() {
overlay
.create(MyModal)
.open()
}
</script>
Beautiful, isn’t it? 😍
Under the hood, this method utilizes the component
directive with a simple v-for
loop to render dialogs.
<script lang="ts" setup>
const { overlays, close } = useOverlay()
</script>
<template>
<component
:is="overlay.component"
v-for="overlay in overlays"
:key="overlay.id"
v-bind="overlay.props"
v-model:open="overlay.modelValue"
/>
</template>
Note
This is a simplified code version.
With this method, extensive state management boilerplate is eliminated, eliminating the need for shared composables or global modals. This significantly improves readability, maintainability, and creation of interactive UI experiences.
Effortless Dialog Stacking
The highlight of this approach is effortless dialog stacking. Merely rendering an array of dialogs via a loop allows simultaneous dialog opening, facilitating elegant stacking with smooth animations.
<script lang="ts" setup>
import DialogFeature1 from './components/DialogFeature1.vue'
const overlay = useOverlay()
function openOverlay() {
overlay
.create(DialogFeature1, {
props: {
title: 'First Modal',
description: 'This is the first modal'
},
destroyOnClose: true
})
.open()
}
</script>
<template>
<div>
<Button label="Open First Overlay" @click="openOverlay" />
<OverlayProvider />
</div>
</template>
<script lang="ts" setup>
import DialogFeature2 from './DialogFeature2.vue'
const props = defineProps<{
title: string
description: string
}>()
const overlay = useOverlay()
function openOverlay() {
overlay
.create(DialogFeature2, {
props: {
title: 'Second Modal',
description: 'This is the second modal'
},
destroyOnClose: true
})
.open()
}
</script>
<template>
<Modal
:title="props.title"
:description="props.description"
>
<template #body>
<Button
label="Open Second Modal"
@click="openOverlay"
/>
</template>
</Modal>
</template>
<script lang="ts" setup>
const props = defineProps<{
title: string
description: string
}>()
</script>
<template>
<Modal
:title="props.title"
:description="props.description"
/>
</template>
Explore the complete code at vue-dialog-stacking and even a live demo.
Final Thoughts
After years of grappling with dialogs and modals and trying several approaches, I finally found a method that works for me and may work for you.
I initially encountered this approach with Angular Material and longed for something similar in Vue. Anthony Fu proposed a promising start with useTemplatePromise
, but it remained complex solely for dialog opening.
Finally, thanks to zernonia for Reka UI and Eugen Istoc for the useOverlay
composable in Nuxt UI, I can confidently manage dialogs and modals properly in Vue.
I'm convinced this approach is a game changer for interactive UI experiences in Vue. Give it a try!
Thanks for reading! My name is Estéban, and I love to write about web development.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!
Discussions
Add a Comment
You need to be logged in to access this feature.