A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.
Components / UI
Video Dialog
A poster with a breathing play button that opens the video in a dialog: a file with native controls, or an embed. The dialog is Dialog (native, invoker-opened, optionally morphing out of the poster); the script does what a closed dialog will not: play on open, pause on close, and for an embed set the iframe src on open and blank it on close so nothing loads or keeps playing behind a closed dialog.
Live demo
click · morphs open
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
604 B raw
374 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.3 kB raw.
CSS 8.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 135 · Firefox 144 · Safari 26.2; needs dialog, command. Elsewhere: without invoker commands the poster needs polyfill to open the dialog, and plays nothing until it does; without JavaScript a file plays with its native controls and an embed's frame is a link to the player.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import VideoDialog from '@moonarc/core/VideoDialog';
---
<VideoDialog id="demo" src={['/demo.webm', '/demo.mp4']} poster="/demo.jpg" title="Product demo" morph tracks={[{ src: '/demo.en.vtt', srclang: 'en', label: 'English' }]} />
<!-- an embed: the iframe gets its src only while the dialog is open -->
<VideoDialog id="talk" embed src="https://www.youtube-nocookie.com/embed/xyz?autoplay=1" poster="/talk.jpg" title="The talk" />Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the shared runtime, Dialog, the morph helper and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/video-dialog.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts, Dialog.astro, internal/DialogMorph.astro and morph.ts.
---
/**
* VideoDialog — a poster with a play button that opens the video in a
* dialog. The trigger is an invoker, so the dialog opens without any
* script; `morph` grows it out of the poster (Dialog morph). The script is
* what a dialog cannot do on its own: a file plays when it opens and stops
* when it closes; an embed's src is set when the dialog opens and blanked
* when it closes, so an iframe never loads behind a closed dialog and never
* keeps talking after it. Measured in phase 7D: a closed dialog does not
* stop sound, in any engine. `tracks` are the file's captions; without
* JavaScript an embed's frame holds a link to the player instead.
*/
import type { HTMLAttributes } from 'astro/types';
import Dialog from './Dialog.astro';
interface Track {
src: string;
/** Language of the track (BCP 47), e.g. 'en'. */
srclang: string;
label?: string;
kind?: 'captions' | 'subtitles' | 'descriptions' | 'chapters';
default?: boolean;
}
interface Props extends HTMLAttributes<'div'> {
/** Dialog id; unique per page. */
id: string;
/** A video file (several formats as an array: webm, mp4), or with `embed` the URL of a player page (YouTube, Vimeo, …). */
src: string | string[];
poster: string;
/** src is an iframe URL. */
embed?: boolean;
/** Grow out of the poster and back into it on a view transition. */
morph?: boolean;
/** Dialog title, for the embed's name and the dialog's. */
title?: string;
/** Accessible name of the play button. */
label?: string;
/** CSS aspect ratio of the poster and the player. */
ratio?: string;
/** Text tracks for a file (WebVTT): captions, subtitles, descriptions. */
tracks?: Track[];
/** Add the inline click handler for browsers without invoker commands. */
polyfill?: boolean;
}
const { id, src, poster, embed = false, morph = false, title = 'Video', label = 'Play video', ratio = '16 / 9', tracks = [], polyfill = false, class: className, style, ...rest } = Astro.props;
const sources = Array.isArray(src) ? src : [src];
const type = (u: string) => (/\.webm(\?|$)/.test(u) ? 'video/webm' : /\.mp4(\?|$)/.test(u) ? 'video/mp4' : undefined);
const inline = [`--ma-vdialog-ratio:${ratio}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const triggerAttrs = { command: 'show-modal', commandfor: id };
---
<div class:list={['ma-vdialog', className]} data-ma-vdialog data-embed={embed ? '' : undefined} style={inline} {...rest}>
<button type="button" class="ma-vdialog__thumb" aria-label={label} {...triggerAttrs}>
<img class="ma-vdialog__poster" src={poster} alt="" draggable="false" />
<span class="ma-vdialog__play" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M8 5.5v13l11-6.5z" fill="currentColor" /></svg>
</span>
</button>
<Dialog id={id} morph={morph} polyfill={polyfill} class="ma-vdialog__dialog" aria-label={title}>
<Fragment slot="trigger" />
<div class="ma-vdialog__frame">
{embed ? (
<>
<iframe class="ma-vdialog__media" data-src={sources[0]} title={title} allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
<noscript><a class="ma-vdialog__link" href={sources[0]}>{title}</a></noscript>
</>
) : (
<video class="ma-vdialog__media" poster={poster} controls playsinline preload="none">
{sources.map((u) => <source src={u} type={type(u)} />)}
{tracks.map((t) => <track src={t.src} srclang={t.srclang} label={t.label} kind={t.kind ?? 'captions'} default={t.default} />)}
</video>
)}
</div>
</Dialog>
</div>
<style is:global>
@layer components {
:where(.ma-vdialog__thumb) {
position: relative;
display: block;
width: 100%;
aspect-ratio: var(--ma-vdialog-ratio, 16 / 9);
padding: 0;
border: 1px solid var(--ma-edge);
border-radius: 1rem;
overflow: clip;
background: var(--ma-ink);
cursor: pointer;
isolation: isolate;
}
:where(.ma-vdialog__thumb:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 3px;
}
:where(.ma-vdialog__poster) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
scale: 1;
transition: scale var(--ma-dur-gentle) var(--ma-ease);
}
:where(.ma-vdialog__thumb:hover .ma-vdialog__poster) {
scale: 1.03;
}
/* the play button, with a ring breathing behind it on the ambient duration */
:where(.ma-vdialog__play) {
position: absolute;
top: 50%;
left: 50%;
display: grid;
place-items: center;
width: 4rem;
height: 4rem;
border-radius: 999px;
background: var(--ma-panel);
color: var(--ma-ink);
box-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.6);
translate: -50% -50%;
transition: scale var(--ma-duration) var(--ma-ease);
}
:where(.ma-vdialog__play)::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--ma-panel);
opacity: 0.5;
animation: ma-vdialog-ring var(--ma-dur-ambient) var(--ma-ease-out) infinite;
}
:where(.ma-vdialog__play svg) {
position: relative;
margin-left: 0.15em;
}
:where(.ma-vdialog__thumb:hover .ma-vdialog__play),
:where(.ma-vdialog__thumb:focus-visible .ma-vdialog__play) {
scale: 1.08;
}
/* the dialog is the player: no padding, the frame's ratio, an ink ground */
:where(.ma-vdialog) .ma-dialog {
width: min(92vw, 64rem);
max-width: none;
overflow: clip;
background: var(--ma-ink);
}
:where(.ma-vdialog) .ma-dialog__panel {
padding: 0;
}
:where(.ma-vdialog) .ma-dialog__close button {
background: var(--ma-panel);
color: var(--ma-ink);
opacity: 0.9;
}
:where(.ma-vdialog__frame) {
position: relative;
aspect-ratio: var(--ma-vdialog-ratio, 16 / 9);
background: var(--ma-ink);
}
/* without JavaScript an embed has no src: the frame links to the player instead */
:where(.ma-vdialog__link) {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
color: var(--ma-panel);
text-align: center;
}
:where(.ma-vdialog__media) {
display: block;
width: 100%;
height: 100%;
border: 0;
background: var(--ma-ink);
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-vdialog__poster),
:where(.ma-vdialog__play) {
transition: none;
scale: 1;
}
:where(.ma-vdialog__play)::before {
animation: none;
opacity: 0;
}
}
}
@keyframes ma-vdialog-ring {
from {
scale: 1;
opacity: 0.5;
}
to {
scale: 1.7;
opacity: 0;
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-vdialog]', (root, { signal }) => {
const dialog = root.querySelector('dialog') as HTMLDialogElement;
const media = dialog.querySelector<HTMLVideoElement | HTMLIFrameElement>('.ma-vdialog__media')!;
const video = media instanceof HTMLVideoElement ? media : null;
// opening: a file plays inside the click, a user gesture the browser honours. One task later the dialog is open, or
// opening (a morph in flight sets html[data-ma-vt]); if neither, the click opened nothing (no invoker commands, no
// polyfill) and nothing may play behind the closed dialog. An embed gets its src then, never while the dialog is shut.
const start = () => {
video?.play().catch(() => {});
setTimeout(() => {
if (!dialog.open && !document.documentElement.hasAttribute('data-ma-vt')) stop();
else if (!video) media.setAttribute('src', media.dataset.src!);
});
};
// closing: a file pauses; an embed is blanked so it stops loading and talking
const stop = () => {
if (video) video.pause();
else media.setAttribute('src', 'about:blank');
};
for (const t of document.querySelectorAll<HTMLElement>(`[commandfor="${dialog.id}"][command="show-modal"]`)) t.addEventListener('click', start, { signal });
dialog.addEventListener('close', stop, { signal });
signal.addEventListener('abort', stop);
});
</script>Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | none | Dialog id; unique per page. |
src | string | string[] | none | A video file (an array is several formats, webm then mp4), or with embed the URL of a player page. |
poster | string | none | The poster, on the trigger and under the file player. |
embed | boolean | false | src is an iframe URL. Its src is set when the dialog opens and blanked when it closes. |
morph | boolean | false | Grow out of the poster and return to it (Dialog morph). |
title | string | 'Video' | Name of the dialog and the iframe. |
label | string | 'Play video' | Accessible name of the play button. |
ratio | string | '16 / 9' | Aspect ratio of the poster and the player. |
tracks | { src: string; srclang: string; label?: string; kind?: 'captions' | 'subtitles' | 'descriptions' | 'chapters'; default?: boolean }[] | [] | WebVTT text tracks for a file, rendered as <track> (kind defaults to captions). An embed brings its own. |
polyfill | boolean | false | Inline the invoker click handler (403 B raw) for browsers without command / commandfor. |
Reduced motion
The play ring stands still and the poster does not scale; the dialog opens with the 150 ms cross-fade (no morph).
With ClientRouter
Bound through the shared runtime; the video is paused (or the embed blanked) before the swap.
Why it is built this way
- The trigger is an invoker button, so the dialog opens with zero script and the file plays with its native controls; the script adds play-on-open because the click is a user gesture the browser will honour. One task after the click it looks again: a dialog that neither opened nor is morphing open (no invoker commands, no polyfill) stops the file at once, so nothing plays behind a closed dialog.
- A closed dialog is display: none, and display: none stops nothing: a video keeps its sound, an iframe keeps its player. Measured in three engines before this was written; the close handler exists because of it.
- An embed gets its src only while the dialog is open: no third-party player loads on a page that merely shows a poster, and blanking on close is the only way to stop a player you do not control.
- The play button breathes on the ambient duration with a ring scaling out behind it. A still poster with a live button reads as "this plays" from across the room.
- morph is the Dialog's: the poster is the box that opens, and it closes back into it.
Replaces: HeroVideoDialog (Magic UI) · VideoModal (Cult UI) · Dialog + video (shadcn / Radix). See the migration table.
Related
Dialog
UIA native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.
A poster card that plays a muted clip while the pointer or focus is on it and rewinds when it leaves.
Lightbox
UIA thumbnail gallery whose images open full size: the image grows out of its own thumbnail on a same-document view transition and returns to it on close, and ← → move through the set.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown