How to build a modal component that lives outside its parent?

I’m building a website that’s structured somewhat like this:

<app>
  <page-wrapper>
    <page-section-that-needs-a-modal>
    </page-section-that-needs-a-modal>
  </page-wrapper>
  <modal>
  </modal>
</app>

Modal is a component that, you guessed it, can hide and show a modal dialog box that appears over everything else. For the sake of argument, let’s say that it needs to be outside of the <page-wrapper> component. (I’m 99% sure that it does need to based on how everything is positioned.) At the moment, I have my project set up with the basic structure above.
To trigger the modal from <page-section-that-needs-a-modal>, I handle a click event in that component that calls a method that emits a show-modal event on an event bus, which is received by the bus in the <app> component, it sets a data property in <app> called showModal to true, and passes HTML from an argument supplied with the event through a prop to the <modal> component. This is fairly straightforward and it works, but there are a few issues:

  1. I can’t really do anything interactive on the modal since the template is just receiving HTML. I’d really like to be able to use Vue events, computed properties, etc. in the modal but I don’t think it’s possible through an event.
  2. This doesn’t feel like the right way to do it.

All of the modal components I’ve seen so far assume that the modal is a child of the component that triggers it, but that won’t work for me. I have a feeling that if there is a solution, it might somehow involve slots, but I can’t quite grok it. Any help/suggestions much appreciated, thanks!

1 Like

Some Time ago I did a little experiment about how to render a part of component’s template somewhere else in the DOM.

This is what I cam up with:

https://jsfiddle.net/Linusborg/dpwzf2sL/

Basically, you have one component to define what you want to move somewhere else (<portal>) and one component to define where that place is (<portal-target>)

<!-- your component:  -->
<portal to="destination-name">
  <modal>
     <!-- modal contents here -->
  </modal>
</portal>

<!-- somewhere else -->
<portal-target name="destination-name">
  <!-- the content of <portal> will appear here, fully functional -->
</portal-target>

Thanks, Linus! This worked perfectly for me. It’s way more elegant and functional than how I had it originally.

Hi @markneub, I’m trying to do the same thing. Could you share your final solution? I’m wondering what the cleanest way is to keep all reactivity and event processing intact…

Hi @rens, sorry to make you wait. I don’t think my code is any neater or easier to read than the simple example @LinusBorg posted, but you can wade through it a little bit if you like. I’m not 100% sure that this is bug free, but here’s how it looks in my project right now.

portal.js

// https://forum.vuejs.org/t/how-to-build-a-modal-component-that-lives-outside-its-parent/4326
// https://jsfiddle.net/Linusborg/dpwzf2sL/

import Vue from 'vue'

var portalBus = new Vue()

Vue.component('portalTarget', {
  name: 'portalTarget',
  props: ['name'],
  created () {
    this.passengers = null
    portalBus.$on(`update:${this.name}`, this.update)
  },
  beforeDestroy () {
    portalBus.$off(`update:${this.name}`, this.update)
  },
  methods: {
    update (passengers) {
      // console.log(`received: update:${this.name}`, this)
      this.passengers = passengers || null
      this.$forceUpdate()
    }
  },
  render (h) {
    var children = this.passengers || null
    return h('div', {
      class: { 'portal-target': true }
    }, children)
  }
})

Vue.component('portal', {
  name: 'portal',
  props: ['to'],
  mounted () {
    this.$nextTick(this.setup)
  },
  updated () {
    this.sendUpdate()
  },
  methods: {
    sendUpdate () {
      // console.log(`to: update:${this.to}`)
      portalBus.$emit(`update:${this.to}`, this.$slots.default)
    },
    setup () {
      this.$nextTick(this.sendUpdate)
    }
  },
  render (h) {
    return h('div', {
      style: { display: 'none' },
      ref: 'portalWrapper'
    })
  }
})

Somewhere in my main layout (app.vue):

<portal-target
  name="modal-target">
</portal-target>

modal-portal.vue:

<template>
  <div>
    <portal to="modal-target" v-show="modalActive">
      <pra-modal :title="caption" >
        <component :is="modalComponent" :src="modalImage" :alt="caption" > </component>
      </pra-modal>
    </portal>
  </div>
</template>

<script>
import bus from '../js/bus'
export default {
  data () {
    return {
      modalComponent: '',
      modalImage: '',
      caption: ''
    }
  },
  created () {
    bus.$on('show-custom-modal', (payload) => {
      this.modalComponent = payload.myComponent
      this.modalImage = payload.modalImage + payload.imageWidth
      this.caption = payload.imageCaption
      bus.$emit('show-modal')
    })
  }
}
</script>

In a component that triggers a modal:

<template>
  <div class="row large-figure">
    <div class="figure-wrap">
      <div data-figure-caption class="caption">{{ caption }}</div>
      <img class="figure" :src="image + '?w=1000'" :alt="caption" :title="caption" @click="showModal(image)">
    </div>

    <modal-portal></modal-portal>

  </div>
</template>

<script>
/* global Image */
import ModalImage from '../modal-image'
import bus from '../../js/bus'
import modalPortal from '../modal-portal.vue'
export default {
  components: {
    ModalImage,
    modalPortal
  },
  props: ['image', 'caption'],
  data () {
    return {
      modalActive: false
    }
  },
  mounted () {
    bus.$on('hide-modal', () => {
      this.modalActive = false
    })

    // preload modal image
    window.setTimeout(() => {
      (new Image()).src = this.modalSrc
    }, 2000)
  },
  methods: {
    showModal (image) {
      this.$nextTick(() => {
        let modalObj = {
          'myComponent': ModalImage,
          'modalImage': this.image,
          'imageWidth': '?w=1540',
          'imageCaption': this.caption
        }
        bus.$emit('show-custom-modal', modalObj)
      })
    }
  },
  computed: {
    modalSrc () {
      return this.image + '?w=1540'
    }
  }
}
</script>