---
contentId: 67862d90-d724-476f-9667-3f35683445ed
title: "A Journey to Craft Interactive UI Experiences with Dialogs"
description: "After years of searching for an effective approach to managing dialogs and modals, I finally discovered a reliable pattern."
date: 2025-03-29
---

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.

<CalloutReadMore id="f9754148-40bd-4df2-a144-f686e894b200" />

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.

<div class="flex justify-center">
  <Modal
    title="Dialog"
    description="This is a dialog"
    :ui="{ content: 'min-w-[280px]' }"
  >
    <Button label="Open Dialog" />
  </Modal>
</div>

Similarly, you can open a slideover by clicking the button.

<div class="flex justify-center">
  <Slideover
    title="Slideover"
    description="This is a slideover"
    :ui="{ content: 'min-w-[280px]' }"
  >
    <Button label="Open Slideover" />
  </Slideover>
</div>

The design and dialog types might seem secondary; the important aspect lies in the underlying code. Let's examine it:

::: code-group

```vue [src/components/Modal.vue]
<template>
  <Modal
    title="Dialog"
    description="This is a dialog"
  >
    <Button label="Open Dialog" />
  </Modal>
</template>
```

```vue [src/components/Slideover.vue]
<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:

1. They can be invoked from anywhere within the application.
2. They open through various methods, such as:
    - Button clicks
    - Key presses
    - Route navigations
3. 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:

<figure>
  <video autoplay loop muted playsinline>
    <source src="https://images.soubiran.dev/posts/a-journey-to-craft-interactive-ui-experiences-with-dialogs/modal-stacking.mp4" type="video/mp4">
  </video>
  <figcaption>Stacking dialogs with a sleek animation.</figcaption>
</figure>

## 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.

```vue [src/components/Modal.vue]
<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:

1. Dialog components become tightly coupled with the utilizing component, complicating comprehension and refactoring.
2. 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:

```vue [src/components/InlineModal.vue]
<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:

```vue [src/App.vue]
<template>
  <div>
    <Layout>
      <RouterView />
    </Layout>

    <ModalFeature1 /> <!-- [!code highlight] -->
    <ModalFeature2 /> <!-- [!code highlight] -->
    <ModalFeature3 /> <!-- [!code highlight] -->

    <!-- 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.

```ts [src/composables/useDialog.ts]
import { ref } from 'vue'

const isOpen = ref(false) // [!code highlight]

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.

```vue [src/App.vue]
<script lang="ts" setup>
import { useDialog } from '@/composables/useDialog'

const { open } = useDialog() // [!code highlight]
</script>

<template>
  <Button label="Open Dialog" @click="open" />
</template>
```

```vue [src/components/ModalFeature1.vue]
<script lang="ts" setup>
import { useDialog } from '@/composables/useDialog'

const { isOpen, close } = useDialog() // [!code highlight]
</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:

1. Inclusion of modals at the app (or route) top-level creates potential code redundancy.
2. Dialog management becomes elaborate, particularly with numerous dialogs or props, each requiring passage through the composable.

    ```ts
    import { ref } from 'vue'

    const isOpen = ref(false)
    const title = ref('') // [!code highlight]
    const description = ref('') // [!code highlight]

    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`](https://vueuse.org/shared/createSharedComposable/) from [`@vueuse/core`](https://vueuse.org/) 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.

```ts [Angular Material Dialog]
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](https://nuxt.com/) released version 3 of [Nuxt UI](https://ui.nuxt.com/) with the `useOverlay` composable. This shared application composable enables dialog opening from any location, mirroring Angular Material functionality.

```vue [src/App.vue]
<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`](https://vuejs.org/guide/essentials/component-basics.html#dynamic-components) directive with a simple [`v-for`](https://vuejs.org/api/built-in-directives.html#v-for) loop to render dialogs.

```vue [OverlayProvider.vue]
<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.

::: code-group

```vue [src/App.vue]
<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>
```

```vue [src/components/DialogFeature1.vue]
<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>
```

```vue [src/components/DialogFeature2.vue]
<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 <GitHubLink repo="barbapapazes/vue-dialog-stacking" /> and even [a live demo](https://vue-dialog-stacking.barbapapazes.dev/).

<figure>
  <iframe
    src="https://vue-dialog-stacking.barbapapazes.dev/"
    width="100%"
    height="600"
    frameborder="0"
    scrolling="no"
    allowfullscreen
    class="bg-white"
  ></iframe>
  <figcaption>Live demo of dialog stacking</figcaption>
</figure>

## 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](https://github.com/antfu) proposed a promising start with [`useTemplatePromise`](https://github.com/antfu/vue-template-promise), but it remained complex solely for dialog opening.

Finally, thanks to [zernonia](https://github.com/zernonia) for [Reka UI](https://reka-ui.com/) and [Eugen Istoc](https://github.com/genu) 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!
