Skip to content

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.

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

Baseline · newly available

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 moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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.

terminal
npx shadcn@latest add https://moonarc.dev/r/video-dialog.json

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

Props

PropTypeDefaultDescription
idstringnoneDialog id; unique per page.
srcstring | string[]noneA video file (an array is several formats, webm then mp4), or with embed the URL of a player page.
posterstringnoneThe poster, on the trigger and under the file player.
embedbooleanfalsesrc is an iframe URL. Its src is set when the dialog opens and blanked when it closes.
morphbooleanfalseGrow out of the poster and return to it (Dialog morph).
titlestring'Video'Name of the dialog and the iframe.
labelstring'Play video'Accessible name of the play button.
ratiostring'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.
polyfillbooleanfalseInline 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

Replaces: HeroVideoDialog (Magic UI) · VideoModal (Cult UI) · Dialog + video (shadcn / Radix). See the migration table.

A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.

every browseralways

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

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

newly · Chrome 135 · Firefox 144 · Safari 26.2click
Study 1Study 2Study 3

click · grows from its thumbnail · ← →

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

newly · Chrome 111 · Firefox 144 · Safari 18click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown