Video Modal
A UModal whose content is a native <video controls> with a spring entrance.
The part worth knowing is not the animation but the lifecycle: the element only
exists while the modal is open, so the component plays it on open and pauses it
on close, driven by a watcher on open.
Because playback is triggered imperatively rather than by the autoplay
attribute alone, closing the modal genuinely stops the audio instead of leaving
it running behind the overlay.
The open watcher is not immediate, so a modal that is already open on first
render does not call play() — it waits for the first change to open.
Usage
Drive open yourself and pass a src; the trigger goes in the default slot. A
poster is worth setting, because it is the frame the user sees during the
entrance and while the browser fetches the first chunk.
The element mounts with the modal and is told to play on open, pause on close.
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<MVideoModal
v-model:open="open"
:src="src"
:poster="poster"
>
<UButton
label="Watch the tour"
icon="i-lucide-play"
/>
</MVideoModal>
<p class="text-xs text-muted">
The element mounts with the modal and is told to play on open, pause on close.
</p>
</div>
</template>
<script setup lang="ts">
const open = ref(false)
const src = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4'
const poster = 'https://picsum.photos/seed/video-modal-poster/800/450'
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | - | Required. Set as the src of the <video> element, which is created with the
modal's content — so changing it while the modal is closed costs nothing, and
changing it while open swaps the source in place. |
poster | string | '' | The poster frame. Set as the native poster attribute and shown before
playback starts; also what is visible during the entrance spring. |
autoplay | boolean | false | Set as the native autoplay attribute. Note that it is effectively
decorative here: the open watcher calls play() unconditionally whenever
the modal opens, regardless of this value, so the video starts either way.
The rejection is swallowed with .catch(() => {}). |
open | boolean | false | Controlled open state. The component holds no state of its own here — it
forwards to UModal and emits update:open back out, so v-model:open is
the intended usage. |
Events
| Event | Payload | Description |
|---|---|---|
update:open | (value: boolean) => void | Emitted from the underlying modal. Dismissal through the overlay, Escape, the
close button, or the trigger all arrive here — this is what makes
v-model:open work. |
Slots
| Slot | Props | Description |
|---|---|---|
default | - | The trigger, forwarded to UModal's default slot. It is the only thing you
can put here; the panel itself is the video element and takes no slot. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.videoModal in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | |
container | - | relative |
video | - | w-full rounded-lg |
Accessibility
Semantics are entirely UModal's: role="dialog", aria-modal, a focus trap,
Escape to dismiss, and focus restored to the trigger on close. The video renders
with the native controls attribute, so playback, seeking, volume, fullscreen
and captions are all keyboard-operable without any extra work.
What the component does not do is label or caption the video. There is no
aria-label, no <track> support and no way to pass attributes to the element —
it is rendered from a fixed set of attributes, so if your video has captions you
will need to reach past this component. Pair it with a transcript link outside
the modal.
There is no reduced-motion branch. The entrance spring
(stiffness: 300, damping: 25, scaling from 0.95) and the matching exit play
unconditionally.
Performance
- The
<video>element is created with the modal's content and destroyed with it, so a closed modal holds no decoder, no buffer and no network connection. - Playback control runs through a
nextTickbefore touching the ref, so the element is guaranteed to exist on the tick the watcher fires — and the code bails out cleanly if it does not. - The rejected-
play()promise is caught and ignored, which keeps a blocked unmuted autoplay from surfacing as an unhandled rejection in the console. opacityandscaleonly; no layout property is animated, so opening the modal does not reflow the page behind it.
play() is called on every open with no muted and no user-gesture check, and
browsers block unmuted programmatic playback that was not preceded by a click.
In practice the user clicks the trigger first, which usually satisfies the
policy — but if the modal is opened programmatically (a route change, a
notification tap), the promise rejects silently and the poster simply stays
until the user presses play.