# Video Dialog (Moonarc)

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.

- Import: `import VideoDialog from '@moonarc/core/VideoDialog'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/video-dialog.json`
- Tier B · category ui · trigger click
- Readout: `<VideoDialog morph>`
- Browser support: newly (Chrome 135 · Firefox 144 · Safari 26.2); 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
- Measured cost: 604 B raw JS · 374 B gzip · + runtime (with dependencies 2.3 kB raw; CSS 8.8 kB raw)
- Page: https://moonarc.dev/components/video-dialog/

## 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. |

## Usage

```astro
<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" />
```

## 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.

## Craft

- 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)

## Source

```astro
---
/**
 * 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>

```
