Skip to content
En esta página

Creando una ventana modal con Vue

En npm se pueden encontrar distintas librerías para ventanas modales, como por ejemplo:

Vue Final Modal

Vue.js Modal

O también están las librerías de UI como Vuetify o BootstrapVue, que incluyen distintos tipos de componentes, incluyendo las modales (también llamadas dialogs).

El problema con usar librerías es que abultan el peso de nuestro build final porque, al incluir muchas funcionalidades distintas (muchas de las cuales no necesitamos) contienen dentro muchísimo código (sobre todo BootstrapVue, que tiene un peso descomunal 😬️).

Y por otro lado, para poder usar las librerías hay que entender la documentación que en general no suele ser lo más clara del mundo, lo cual aumenta nuestra carga cognitiva.

Por eso siempre que podamos es mejor crear nuestra propia solución. Se podría pensar que esto es una pérdida de tiempo (lo que se suele llamar reinventing the wheel). En mi opinión, intentar encontrar una solución con las herramientas nativas del framework siempre nos sirve para aprender a usarlo mejor.

Y en el caso de las ventanas modales, al no ser un componente muy complejo, creo que no tiene mucho sentido usar librerías.

Creando una modal sin librerías

Entonces, ¿cómo creamos la modal?

En el objeto data del componente padre de la modal (en este caso App.vue) declaramos un booleano (showModal) para mostrar u ocultar la ventana según su valor sea true o false, y en el template agregamos un botón que dispare un método que cambie el valor de este booleano:

html
<template>
  <main>
    
    <h1>App.vue</h1>

    <button @click="openModal">Abrir Modal</button>

  </main>
</template>

<script>

export default {

  data: () => ({ 
    showModal: false
  }),

  methods: {
    openModal() {
      this.showModal = true
    }
  }
}

</script>

Luego creamos un componente llamado VentanaModal.vue que reciba como prop el valor de showModal y que emita un evento (close-modal) cuando querramos cerrar la ventana. Este evento lo ponemos tanto en el botón como en el elemento dialog para que se cierre también cuando el usuario cliquea por fuera de la ventana:

html
<template>

  <dialog v-if="showModal" @click="closeModal">

    <article>

      <header>
        <h3>Ventana Modal</h3>
        <p>Esta es una ventana modal creada con un componente de Vue.js</p>
      </header>
  
      <footer>
        <button @click="closeModal">
          Cerrar
        </button>
      </footer>

    </article> 
  </dialog>
</template>

<script>

export default {

  name: 'VentanaModal',

  props: {
    showModal: Boolean
  },

  emits: ['close-modal'],

  methods: {
    closeModal() {
      this.$emit('close-modal')
    }
  }
}
</script>

Todo lo que esté dentro del elemento dialog se va a mostrar cuando la prop showModal sea true.

En vez de emitir el evento close-modal al componente padre podríamos decir que sería más fácil hacer esto:

js
methods: {
    closeModal() {
      this.showModal = false
    }
  }

Sí, funciona, pero Vue les va a dar el siguiente mensaje de error:

❌️

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "showModal"

found in

---> VentanaModal at /src/components/VentanaModal.vue App at /src/App.vue Root

Es decir, nunca debemos cambiar en forma directa el valor de una prop, porque el valor podría ser pisado cuando el componente padre renderice nuevamente.

En este caso particular funciona igual, porque el valor inicial de showModal en el componente padre también es false. Pero podría haber casos en que el nuevo valor que le damos a la prop difiera del valor inicial y ahí la aplicación se rompe 😬️

Aplicando una máscara de CSS

Cuando se muestre la modal, alrededor queremos que el fondo se oscurezca y que esté un poco fuera de foco:

GitHub Pages

Para eso tenemos que crear una máscara con CSS, que aplicamos al elemento dialog:

html
<style scoped>

dialog {
  display: flex;
  position: fixed;
  z-index: 1000;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(2px);
}

<style>

En Vue es posible limitar el alcance de los bloques de CSS con el atributo scoped, por lo que en este caso no es necesario usar class para darle estilos a dialog. El selector de CSS puede ser simplemente el tag de HTML.

El z-index de dialog es 1000 para que la máscara esté por encima de todos los demás componentes. Y la posición tiene que abarcar todo el viewport, o sea, todo lo que se ve en el browser.

El efecto de fuera de foco se lo damos con backdrop-filter.

Luego agregamos otros bloques de CSS para el contenido de la modal: el elemento article, el header, el footer y el botón para cerrar:

css
article {
  position: relative;
  width: 26rem;
  height: fit-content;
  margin: auto;
  padding: 1rem;
  text-align: left;
  background-color: snow;
  border-radius: 0.3rem;
  box-shadow: 0 0.4rem 0.2rem 0 rgba(0,0,0,0.5);
}

h3 {
  margin-bottom: 1rem;
}

header {
  margin: 0.5rem 1rem 2rem 1rem;
}

footer {
  position: absolute;
  top: auto;
  left: 0;
  width: 100%;
  height: 5rem;
  padding: 1.4rem;
  background-color: whitesmoke;
  border-radius: 0 0 0.3rem 0.3rem;
  border-top: 1px solid lightgrey;
}

button {
  float: right;
  background-color: navy;
  color: snow;
  border-radius: 0.3rem;
  border: none;
  opacity: 0.8;
}

button:hover {
  opacity: 1;
}

El footer tiene una posición absoluta con respecto a article para que quede alineado en la modal. Y la posición del botón se la damos con float-right para que quede del lado derecho.

💡️ rem vs. px

Para las dimensiones de CSS siempre es mejor usar rem en vez de px porque rem mantiene las proporciones cuando el usuario le da zoom al browser o cuando cambia el tamaño de letra por defecto.

Y ya podemos importar el componente de la modal en App.vue:

html
<template>
  <main>
    
    <h1>App.vue</h1>

    <button @click="openModal">Abrir Modal</button>
     
    <VentanaModal 
      :showModal="showModal"
      @close-modal="closeModal"  
    />

  </main>
</template>

<script>

import VentanaModal from '@/components/VentanaModal.vue'

export default {

  components: {
    VentanaModal
  },

  data: () => ({ 
    showModal: false
  }),

  methods: {
    openModal() {
      this.showModal = true
    }
  }
}

</script>

Y listo, ya tenemos nuestra ventana modal 🥳️🥳️🥳️

Pero hay un problema: la ventana debería cerrarse únicamente si clickeamos por fuera de la ventana o en el botón que dice Cerrar. Pero, como podrán ver, la ventana se cierra al clickear en cualquier lado.

@click.self

Para solucionar este problema Vue tiene el modificador .self que permite especificar que el evento de click sea escuchado únicamente en el elemento HTML sobre el que se hizo el click (en este caso dialog) pero no en sus hijos (en este caso, article, header, footer, etc):

html
<template>

  <dialog v-if="showModal" @click.self="closeModal"> 

    <article>

      <header>
        <h3>Ventana Modal</h3>
        <p>Esta es una ventana modal creada con un componente de Vue.js</p>
      </header>
  
      <footer>
        <button @click="closeModal">
          Cerrar
        </button>
      </footer>

    </article> 
  </dialog>
</template>

Y hay otro problema: la ventana aparece de forma un poco brusca. Sería bueno que la transición sea más suave.

Usando transition

Para facilitar la creación de efectos de transición Vue cuenta con el componente nativo transition.

Este componente debe ser el contenedor de todos los elementos a los que se les aplica el efecto de transición:

html
<template>

  <transition> 
    
    <dialog v-if="showModal" @click.self="closeModal">
  
      <article>
  
        <header>
          <h3>Ventana Modal</h3>
          <p>Esta es una ventana modal creada con un componente de Vue.js</p>
        </header>
    
        <footer>
          <button @click="closeModal">
            Cerrar
          </button>
        </footer>
  
      </article>
    </dialog>
  </transition> 
</template>

Luego, al bloque de CSS para la dialog le agregamos la declaración transition: opacity 0.5 ease para darle un efecto de transición suave de 0,5 segundos. Y agregamos un par de clases de CSS para el componente transition. Las clases del componente transition deben ser nombradas con el prefijo .v- (.v-enter para el estilo de CSS al renderizar el componente y .v-leave-to para el estilo cuando el componente desaparece):

css
dialog {
  display: flex;
  position: fixed;
  z-index: 1000;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(0.1rem);
  transition: opacity 0.5s ease; 
}

/* En Vue 3 es .v-enter-from  no .v-enter*/
.v-enter, .v-leave-to { 
  opacity: 0; 
} 

💡️

No es necesario aplicarle al componente <transition> estas clases de CSS en forma directa. Al nombrarlas anteponiendo .v- Vue aplica esas clases automáticamente a los elementos dentro de <transition>.

Y ahora ya tenemos la ventana con transición suave 🥳️

Además, noten que ahora la ventana se cierra únicamente al hacer click por fuera de ella o en el botón, pero en ningún otro lado.

Y todavía hay algo que podríamos mejorar: hacer que la modal pueda tener cualquier contenido dentro, no sólo un título y un texto, según lo que se le pase desde el componente padre. Para eso se pueden usar los slots.

Usando slots

Los slots son otro componente nativo de Vue que permite insertar bloques de HTML dentro de un componente. Serían como las props, pero en vez de enviar valores del componente padre al hijo enviamos HTML.

Por ejemplo, podemos hacer que en el header la modal contenga un bloque de HTML con alguna imagen que le insertamos desde App.vue. Para eso primero tenemos que declarar el slot en el componente VentanaModal, dándole en el atributo name un nombre para identificar el slot (en este caso lo llamamos encabezado):

html
<template>

  <transition>
    
    <dialog v-if="showModal" @click.self="closeModal">
  
      <article>
  
        <header>
          <slot name="encabezado"/> 
        </header>
    
        <footer>
          <button @click="closeModal">
            Cerrar
          </button>
        </footer>
  
      </article>
    </dialog>
  </transition>
</template>

Y en el componente padre le insertamos el bloque de HTML dentro de un elemento template con el nombre del slot anteponiendo el signo #, y adentro de este template el bloque de HTML que querramos insertarle a la modal:

html
<template>

  <div>

  <button @click="openModal">Abrir Modal</button>

  <VentanaModal
    :showModal="showModal"
    @close-modal="closeModal"  
  >
    <template #encabezado>

      <h3>Cómo cerrar una ventana modal</h3>
      <p>Para cerrar esta ventana modal toque el botón que dice "Cerrar"</p>
      <img
        src="https://dav-leda.github.io/images-fl/vintage-touchscreen.jpg" 
        alt="vintage touchscreen"
        width="300"
      />
      
    </template>
  
  </VentanaModal>

  </div>
</template>

Y ahora tenemos la ventana modal con un slot 🥳️

Slot + @click

¿Y si también queremos pasarle el botón de cerrar por slots?

Podemos hacerlo, pero tenemos que tener cuidado con algo: aunque a nivel visual el botón está dentro del componente de la modal, a nivel lógico está en el componente padre. Por lo tanto, el evento @click no debe disparar un emit sino que simplemente modifica el estado de la propiedad showModal en el componente padre.

Primero, en VentanaModal agregamos el slot donde va a insertarse el botón:

html
<header>
    <slot name="encabezado"/>
  </header>

  <footer>
    <slot name="boton"/> 
  </footer>

Y luego, en el componente padre agregamos el template del botón:

html
<template>

  <div>

  <button @click="openModal">Abrir Modal</button>

  <VentanaModal
    :showModal="showModal"
    @close-modal="closeModal"  
  >
    <template #encabezado>
      <h3>Cómo cerrar una ventana modal</h3>
      <p>Para cerrar esta ventana modal toque el botón que dice "Cerrar"</p>
      <img 
        src="https://dav-leda.github.io/images-fl/vintage-touchscreen.jpg" 
        alt="vintage touchscreen"
        width="300"
      />
      <br>
    </template>

    <template #boton>
      <button @click="closeModal"> 
        Cerrar
      </button>
    </template>
  
  </VentanaModal>

  </div>
</template>

<script>

import VentanaModal from '@/components/VentanaModal.vue'

export default {

  components: {
    VentanaModal
  },

  data: () => ({ 
    showModal: false
  }),

  methods: {
    openModal() {
      this.showModal = true
    },
    closeModal() {
      this.showModal = false
    }
  }
}
</script>

Y ahora el click sobre el botón no dispara un emit porque el botón ya está dentro del componente padre. En este caso el método closeModal modifica el valor de showModal en forma directa.

Y para estilar este botón del slot con CSS tienen que hacerlo en el componente padre, no en VentanaModal, ya que el elemento button está dentro del componente padre.

Y así quedaría nuestra modal con el botón en el slot:

Slots + Componentes

Si lo que quieren hacer es pasarle a la modal un componente mediante slots funciona igual que con cualquier otro elemento HTML, se lo pasan dentro del template con el nombre del slot (en este caso se llama #encabezado).

Por ejemplo, si quieren pasarle a la modal por slots una tabla con los productos en el carrito (CartTable):

html
<template>

  <div>

  <button @click="openModal">Abrir Modal</button>

  <VentanaModal
    :showModal="showModal"
    @close-modal="closeModal"  
  >
    <template #encabezado>
      <CartTable/> 
    </template>

    <template #botones>
      <button class="confirm" @click="confirmOrder">
          Confirmar Compra
        </button>
        <button class="close" @click="cancelOrder">
          Cancelar
        </button>
    </template>
  
  </VentanaModal>

  </div>
</template>

Y quedaría así:

Usando Teleport en Vue 3

En Vue 3 se introdujo teleport, un nuevo componente nativo para facilitar la creación de ventanas modales.

Esto es porque en aplicaciones complejas, con muchos componentes con distintas clases de CSS, puede ocurrir que la máscara de la ventana modal no cubra toda la vista y que algunos elementos (por ejemplo, la NavBar) queden por fuera de esta máscara. Para solucionar esto teleport inserta el componente de la modal por fuera del div con id #app, es decir, por fuera de nuestra aplicación Vue.

Aunque visualmente la modal quede por fuera de la aplicación, cubriendo todos los componentes (por ejemplo, insertada en el body del archivo index.html), a nivel lógico sigue estando dentro.

En caso de que estén usando Vue 3 y necesiten usar teleport lo único que tienen que hacer es envolver todos los elementos de la modal en el componente <teleport> y agregarle el atributo to="body":

html
<template>

  <teleport to="body"> 

    <transition>
      
      <dialog v-if="showModal" @click.self="closeModal">
    
        <article>
    
          <header>
            <slot name="encabezado"/>
          </header>
      
          <footer>
            <slot name="boton"/>
          </footer>
    
        </article>
      </dialog>
    </transition>
  </teleport> 
</template>

El repositorio de esta modal lo pueden ver acá (es la versión en Vue 3 con TypeScript).