· Updated on

How to Build a Modal Video Component with Tailwind CSS and Vue

Create a video modal in Tailwind CSS

Welcome to the third and final part of our series on How to Build a Video Modal Component! In this tutorial, we will create a fully-functional video modal component using Vue and Tailwind CSS, complete with TypeScript support.

Please note that we won’t cover the step-by-step process of setting up a Vue app. However, we highly recommend using Vite as a build tool, as it makes creating a Vue app effortless with a simple command in the Terminal.

After you’ve set up your app, the next step is to install Tailwind CSS and import the Tailwind directives into your CSS file. If you need inspiration, you can check out how we previously built this component in Talent, a recruitment website template, and Docs, a documentation website template.

Let’s get started! We’ll be using a single-file component and naming it ModalVideo.vue.

<script setup lang="ts">
</script>

<template>
    <div>
        <!-- 1. The button -->
        <!-- 2. The backdrop layer --> 
        <!-- 3. The modal video -->
    </div>
</template>

Within the template tag, we will include the three essential elements required to create our component: the button, backdrop, and modal housing the video.

Define the modal initial state

We need to establish the initial state of our modal – that can either be open or closed – and we’ll accomplish this by using a boolean ref that has a default value of false (i.e., closed). We’ll then assign this ref to the variable modalOpen:

<script setup lang="ts">
import { ref } from 'vue'
const modalOpen = ref<boolean>(false)
</script>

<template>
    <div>
        <!-- 1. The button -->
        <!-- 2. The backdrop layer --> 
        <!-- 3. The modal video -->
    </div>
</template>

Toggling the modal state

Now, when a user clicks on the thumbnail, we want modalOpen to change to true. Vue provides v-on directives that allow us to listen to DOM events and execute some JavaScript when they’re triggered.

In this case, we’ll be using the v-on:click directive (or @click in its shortened version) to change the modalOpen state upon clicking:

<script setup lang="ts">
import { ref } from 'vue'
const modalOpen = ref<boolean>(false)
</script>

<template>
    <div>

        <!-- 1. The button -->
        <button
            class="relative flex justify-center items-center focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 rounded-3xl group"
            @click="modalOpen = true"
            aria-label="Watch the video"
        >
            <img class="rounded-3xl shadow-2xl transition-shadow duration-300 ease-in-out" src="../assets/modal-video-thumb.jpg" width="768" height="432" alt="Modal video thumbnail" />
            <!-- Play icon -->
            <svg class="absolute pointer-events-none group-hover:scale-110 transition-transform duration-300 ease-in-out" xmlns="<http://www.w3.org/2000/svg>" width="72" height="72">
                <circle class="fill-white" cx="36" cy="36" r="36" fill-opacity=".8" />
                <path class="fill-indigo-500 drop-shadow-2xl" d="M44 36a.999.999 0 0 0-.427-.82l-10-7A1 1 0 0 0 32 29V43a.999.999 0 0 0 1.573.82l10-7A.995.995 0 0 0 44 36V36c0 .001 0 .001 0 0Z" />
            </svg>
        </button>

        <!-- 2. The backdrop layer --> 
        <!-- 3. The modal video -->

    </div>
</template>

Handling modal visibility and adding enter / leave transitions

At this point, we need to link the modal’s visibility to the modalOpen variable state and display it when true while hiding it when false. Additionally, we want the modal with the video to enter smoothly with a scale-up transition and exit with a scale-down.

Although Vue provides a built-in Transition component to accomplish this, we prefer using the Headless UI library because it offers a Dialog component that’s readily available and fully-accessible.

Let’s install the Headless UI for Vue version using the command npm install @headlessui/vue in the terminal and import the required components into our component file, which we’ll use as follows:

<script setup lang="ts">
import { ref } from 'vue'
import {
    Dialog,
    DialogPanel,
    TransitionRoot,
    TransitionChild,
} from '@headlessui/vue'

const modalOpen = ref<boolean>(false)
</script>

<template>
    <div>

        <!-- 1. The button -->
        <button
            class="relative flex justify-center items-center focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 rounded-3xl group"    
            @click="modalOpen = true"
            aria-label="Watch the video"
        >
            <img class="rounded-3xl shadow-2xl transition-shadow duration-300 ease-in-out" src="../assets/modal-video-thumb.jpg" width="768" height="432" alt="Modal video thumbnail" />
            <!-- Play icon -->
            <svg class="absolute pointer-events-none group-hover:scale-110 transition-transform duration-300 ease-in-out" xmlns="<http://www.w3.org/2000/svg>" width="72" height="72">
                <circle class="fill-white" cx="36" cy="36" r="36" fill-opacity=".8" />
                <path class="fill-indigo-500 drop-shadow-2xl" d="M44 36a.999.999 0 0 0-.427-.82l-10-7A1 1 0 0 0 32 29V43a.999.999 0 0 0 1.573.82l10-7A.995.995 0 0 0 44 36V36c0 .001 0 .001 0 0Z" />
            </svg>
        </button>

        <TransitionRoot :show="modalOpen" as="template">
            <Dialog @close="modalOpen = false">

                <!-- Modal backdrop -->
                <TransitionChild
                    className="fixed inset-0 z-[99999] bg-black bg-opacity-50 transition-opacity"
                    enter="transition ease-out duration-200"
                    enterFrom="opacity-0"
                    enterTo="opacity-100"
                    leave="transition ease-out duration-100"
                    leaveFrom="opacity-100"
                    leaveTo="opacity-0"
                    aria-hidden="true"
                />
                <!-- End: Modal backdrop -->

                <!-- Modal dialog -->
                <TransitionChild
                    className="fixed inset-0 z-[99999] flex p-6"
                    enter="transition ease-out duration-300"
                    enterFrom="opacity-0 scale-75"
                    enterTo="opacity-100 scale-100"
                    leave="transition ease-out duration-200"
                    leaveFrom="opacity-100 scale-100"
                    leaveTo="opacity-0 scale-75"
                >
                    <div class="max-w-5xl mx-auto h-full flex items-center">
                        <DialogPanel class="w-full max-h-full rounded-3xl shadow-2xl aspect-video bg-black overflow-hidden">
                            <video loop controls>
                                <source src="../assets/video.mp4" width="1920" height="1080" type="video/mp4" />
                                Your browser does not support the video tag.
                            </video>
                        </DialogPanel>
                    </div>
                </TransitionChild>
                <!-- End: Modal dialog -->

            </Dialog>
        </TransitionRoot>

    </div>
</template>

If you read our previous article on creating a video modal with Next.js, you may have noticed that the structure of this component is quite similar, but with some small differences:

  • We have a TransitionRoot component that contains both the backdrop and the modal, which can receive the modal’s state through the show property and transmit it to the two TransitionChild components.
  • The TransitionChild components use the enter, enterFrom, enterTo, leave, leaveFrom, and leaveTo properties to define the CSS classes that define the entrance and exit transitions.
  • The Dialog component provides a @close event listener that allows us to set the modalOpen variable to false when clicking outside the dialog element or pressing the escape key. By doing so, the modal will automatically close.

Although the component is fully functional at this point, some adjustments may still be necessary. For example, we may want the video to start playing automatically when the modal is opened. Let’s see how to do it.k

Playing the video automatically when the modal opens

To start, we need to reference the video element so that we can initiate playback. This is done using a ref. Then, leveraging the power of the Composition API, we can use a watcher that detects any changes in the modalOpen variable. This watcher will play the videoRef automatically when it appears in the DOM. Thanks to TypeScript, we can check the existence of videoRef with a single line of code:

watch(videoRef, () => {
    videoRef.value?.play()
})

Finally, we can use the initialFocus property provided by the Dialog component to give focus to the video when the modal is opened.

And here’s the final code:

<script setup lang="ts">
import { ref } from 'vue'
import {
    Dialog,
    DialogPanel,
    TransitionRoot,
    TransitionChild,
} from '@headlessui/vue'

const modalOpen = ref<boolean>(false)
const videoRef = ref<HTMLVideoElement | null>(null)

watch(videoRef, () => {
    videoRef.value?.play()
})
</script>

<template>
    <div>

        <!-- 1. The button -->
        <button
            class="relative flex justify-center items-center focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 rounded-3xl group"    
            @click="modalOpen = true"
            aria-label="Watch the video"
        >
            <img class="rounded-3xl shadow-2xl transition-shadow duration-300 ease-in-out" src="../assets/modal-video-thumb.jpg" width="768" height="432" alt="Modal video thumbnail" />
            <!-- Play icon -->
            <svg class="absolute pointer-events-none group-hover:scale-110 transition-transform duration-300 ease-in-out" xmlns="<http://www.w3.org/2000/svg>" width="72" height="72">
                <circle class="fill-white" cx="36" cy="36" r="36" fill-opacity=".8" />
                <path class="fill-indigo-500 drop-shadow-2xl" d="M44 36a.999.999 0 0 0-.427-.82l-10-7A1 1 0 0 0 32 29V43a.999.999 0 0 0 1.573.82l10-7A.995.995 0 0 0 44 36V36c0 .001 0 .001 0 0Z" />
            </svg>
        </button>

        <TransitionRoot :show="modalOpen" as="template">
            <Dialog :initialFocus="videoRef" @close="modalOpen = false">

                <!-- Modal backdrop -->
                <TransitionChild
                    className="fixed inset-0 z-[99999] bg-black bg-opacity-50 transition-opacity"
                    enter="transition ease-out duration-200"
                    enterFrom="opacity-0"
                    enterTo="opacity-100"
                    leave="transition ease-out duration-100"
                    leaveFrom="opacity-100"
                    leaveTo="opacity-0"
                    aria-hidden="true"
                />
                <!-- End: Modal backdrop -->

                <!-- Modal dialog -->
                <TransitionChild
                    className="fixed inset-0 z-[99999] flex p-6"
                    enter="transition ease-out duration-300"
                    enterFrom="opacity-0 scale-75"
                    enterTo="opacity-100 scale-100"
                    leave="transition ease-out duration-200"
                    leaveFrom="opacity-100 scale-100"
                    leaveTo="opacity-0 scale-75"
                >
                    <div class="max-w-5xl mx-auto h-full flex items-center">
                        <DialogPanel class="w-full max-h-full rounded-3xl shadow-2xl aspect-video bg-black overflow-hidden">
                            <video ref="videoRef" loop controls>
                                <source src="../assets/video.mp4" width="1920" height="1080" type="video/mp4" />
                                Your browser does not support the video tag.
                            </video>
                        </DialogPanel>
                    </div>
                </TransitionChild>
                <!-- End: Modal dialog -->

            </Dialog>
        </TransitionRoot>

    </div>
</template>

You can import and use the completed component in another component by using:

<ModalVideo />

Now, let’s explore how to modify the component to make it reusable by allowing for different thumbnails and videos to be passed in.

Making the modal video component reusable

The properties we want to pass to the ModalVideo component are:

  • The source attribute (src) of the thumbnail image
  • The dimensions of the thumbnail image
  • The alternative text (alt) for the thumbnail image
  • The source attribute (src) of the video
  • The dimensions of the video
<ModalVideo
    :thumb="VideoThumb"
    :thumbWidth="768"
    :thumbHeight="432"
    thumbAlt="Modal video thumbnail"
    :video="VideoSrc"
    :videoWidth="1920"
    :videoHeight="1080" />

Let’s now go back to the ModalVideo.vue file and list the properties, also defining an object with a TypeScript interface:

interface Props {
    thumb: string
    thumbWidth: number
    thumbHeight: number
    thumbAlt: string
    video: string
    videoWidth: number
    videoHeight: number
}

const props = defineProps<Props>()

Conclusions

So, here is the final result of the Modal Video component, built using Vue, TypeScript, and Tailwind CSS:

<script setup lang="ts">
import { ref, watch } from 'vue'
import {
    Dialog,
    DialogPanel,
    TransitionRoot,
    TransitionChild,
} from '@headlessui/vue'

const modalOpen = ref<boolean>(false)
const videoRef = ref<HTMLVideoElement | null>(null)

watch(videoRef, () => {
    videoRef.value?.play()
})

interface Props {
    thumb: string
    thumbWidth: number
    thumbHeight: number
    thumbAlt: string
    video: string
    videoWidth: number
    videoHeight: number
}

const props = defineProps<Props>()
</script>

<template>
    <div>

        <!-- Video thumbnail -->
        <button
            class="relative flex justify-center items-center focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 rounded-3xl group"    
            @click="modalOpen = true"
            aria-label="Watch the video"
        >
            <img class="rounded-3xl shadow-2xl transition-shadow duration-300 ease-in-out" :src="props.thumb" :width="props.thumbWidth" :height="props.thumbHeight" :alt="props.thumbAlt" />
            <!-- Play icon -->
            <svg class="absolute pointer-events-none group-hover:scale-110 transition-transform duration-300 ease-in-out" xmlns="<http://www.w3.org/2000/svg>" width="72" height="72">
                <circle class="fill-white" cx="36" cy="36" r="36" fill-opacity=".8" />
                <path class="fill-indigo-500 drop-shadow-2xl" d="M44 36a.999.999 0 0 0-.427-.82l-10-7A1 1 0 0 0 32 29V43a.999.999 0 0 0 1.573.82l10-7A.995.995 0 0 0 44 36V36c0 .001 0 .001 0 0Z" />
            </svg>
        </button>
        <!-- End: Video thumbnail -->

        <TransitionRoot :show="modalOpen" as="template">
            <Dialog :initialFocus="videoRef" @close="modalOpen = false">

                <!-- Modal backdrop -->
                <TransitionChild
                    className="fixed inset-0 z-[99999] bg-black bg-opacity-50 transition-opacity"
                    enter="transition ease-out duration-200"
                    enterFrom="opacity-0"
                    enterTo="opacity-100"
                    leave="transition ease-out duration-100"
                    leaveFrom="opacity-100"
                    leaveTo="opacity-0"
                    aria-hidden="true"
                />                
                <!-- End: Modal backdrop -->

                <!-- Modal dialog -->
                <TransitionChild
                    className="fixed inset-0 z-[99999] flex p-6"
                    enter="transition ease-out duration-300"
                    enterFrom="opacity-0 scale-75"
                    enterTo="opacity-100 scale-100"
                    leave="transition ease-out duration-200"
                    leaveFrom="opacity-100 scale-100"
                    leaveTo="opacity-0 scale-75"
                >                
                    <div class="max-w-5xl mx-auto h-full flex items-center">
                        <DialogPanel class="w-full max-h-full rounded-3xl shadow-2xl aspect-video bg-black overflow-hidden">
                            <video ref="videoRef" loop controls>
                                <source :src="props.video" :width="props.videoWidth" :height="props.videoHeight" type="video/mp4" />
                                Your browser does not support the video tag.
                            </video>
                        </DialogPanel>
                    </div>
                </TransitionChild>
                <!-- End: Modal dialog -->
                
            </Dialog>
        </TransitionRoot>

    </div>
</template>

As we discussed in the first and second parts of this series, you can use this component in a variety of ways (e.g., product tours, app presentations, etc.), as it works well with any type of website, landing page, or web app.

If you’re interested in learning how to build this component in Alpine.js and Next.js, check out the first and second parts of the series: