Skip to content
Brok UI

Loading…

No results

Hero Video Dialog

Open source

An immersive thumbnail card with a centered play button that opens an accessible modal lightbox with the embedded video and an optional sound toggle.

Version
v1.4.2
Stability
stable
License
MIT
Related
Compare Slider
Blur Vignette

Preview

previews.components.hero-video-dialog.default.blade.php Blade
<div class="w-full flex justify-center px-4 py-8">
    <x-ui.hero-video-dialog title="Watch the product tour" />
</div>

Installation

terminal
php artisan ui:add hero-video-dialog

Note

This component ships an Alpine behavior module at resources/js/ui/hero-video-dialog.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './hero-video-dialog.js';

Registry contract

php artisan ui:add hero-video-dialog writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/hero-video-dialog.blade.php
  • js resources/js/ui/hero-video-dialog.js
Registry dependencies
dialog
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

hero-video-dialog.md
# Brok UI: Hero Video Dialog (`hero-video-dialog`)

An immersive thumbnail card with a centered play button that opens an accessible modal lightbox with the embedded video and an optional sound toggle.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add hero-video-dialog
```

## Usage

```blade
<div class="w-full flex justify-center px-4 py-8">
    <x-ui.hero-video-dialog title="Watch the product tour" />
</div>
```

## Props

- `videoSrc` (string, default `https:`) — Embed URL for the video played inside the lightbox.
- `thumbnailSrc` (mixed|null, default `null`) — Poster image shown on the closed card before the dialog opens.
- `thumbnailAlt` (string, default `Video thumbnail`) — Accessible alt text for the thumbnail image.
- `soundSrc` (mixed|null, default `null`) — Optional separate audio track URL; when set, a mute/unmute toggle is rendered alongside the video.
- `enableSound` (bool, default `true`) — Whether the separate audio track starts unmuted; only relevant when soundSrc is set.
- `title` (string, default `Watch the product tour`) — Accessible label for the play button and the opened dialog.

## Use when

- Use when image or video selection, preview, or media-based comparison is part of the task.
- Presenting a promotional or product-tour video behind an immersive thumbnail card that opens into a focused lightbox on click.
- Wanting a lightweight sound toggle for a separate ambient audio track alongside a muted background video.

## Avoid when

- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- The video should play inline as part of the page content; embed the video player directly instead of behind a dialog.
- The content is not a single hero video; use dialog directly for a general-purpose modal.

## Anti-patterns

- Requiring media-heavy interaction when text is faster

## Rules

- Use the `<brok:hero-video-dialog>` tag (or `<x-ui.hero-video-dialog>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/hero-video-dialog
- Registry JSON (files, props, contract): https://brokui.dev/r/open/hero-video-dialog.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
videoSrc string https: Embed URL for the video played inside the lightbox.
thumbnailSrc mixed | null null Poster image shown on the closed card before the dialog opens.
thumbnailAlt string Video thumbnail Accessible alt text for the thumbnail image.
soundSrc mixed | null null Optional separate audio track URL; when set, a mute/unmute toggle is rendered alongside the video.
enableSound bool true Whether the separate audio track starts unmuted; only relevant when soundSrc is set.
title string Watch the product tour Accessible label for the play button and the opened dialog.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

hero-video-dialog

Behavior

  • Clicking the thumbnail card opens the video inside a focused, close-button-dismissable dialog with focus trapping, scroll lock, and focus restoration on close.
  • The lightbox is the dialog primitive (composed with overlay="blur", padding="none", size="xl"); the iframe src is set in the dialog's onOpen hook and cleared in onClose, so the video only loads and plays while the lightbox is open.
  • When soundSrc is provided, a mute toggle controls a separate looping audio element that plays alongside the video, independent of the embedded video's own audio.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Media selection and presentation

Select, preview, or compare visual media.

Use when

  • Use when image or video selection, preview, or media-based comparison is part of the task.
  • Presenting a promotional or product-tour video behind an immersive thumbnail card that opens into a focused lightbox on click.
  • Wanting a lightweight sound toggle for a separate ambient audio track alongside a muted background video.

Avoid when

  • Do not require media-heavy interaction when a simpler text or list solution would be faster.
  • The video should play inline as part of the page content; embed the video player directly instead of behind a dialog.
  • The content is not a single hero video; use dialog directly for a general-purpose modal.

Use instead

  • Text or list representation

Anti-patterns

  • Requiring media-heavy interaction when text is faster
Anatomy
root
Theming hooks
hero-video-dialog

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Tab Escape
Focus
native
  • The dialog traps focus, closes on Escape or its close button, and restores focus to the thumbnail trigger when it closes.
  • The mute toggle uses aria-pressed and a live aria-label that switches between Mute and Unmute, so its state is announced correctly.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="hero-video-dialog-{{ $record->id }}">
    <div class="w-full flex justify-center px-4 py-8">
        <x-ui.hero-video-dialog title="Watch the product tour" />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:hero-video-dialog
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/hero-video-dialog.blade.php Blade
@props([
    'videoSrc' => 'https://www.youtube.com/embed/dQw4w9WgXcQ',
    'thumbnailSrc' => null,
    'thumbnailAlt' => 'Video thumbnail',
    'soundSrc' => null,
    'enableSound' => true,
    'title' => 'Watch the product tour',
])

{{--
    Hero Video Dialog — an immersive thumbnail card with a centered circular play
    button that opens a modal lightbox containing the embedded video. Mirrors
    magicui's HeroVideoDialog, adapted to our tokens. The lightbox is
    <x-ui.dialog> with the `blur` overlay, no panel inset and the `xl` width:
    focus trap, Escape/backdrop/close-button dismissal, scroll lock, the inert
    sweep and focus return all come from the dialog. This item only owns the
    thumbnail trigger, the iframe `src` lifecycle (set in the dialog's `onOpen`
    hook, cleared in `onClose` so playback stops) and the optional sound toggle
    (rendered only when `soundSrc` is provided). Behaviour lives in
    `uiHeroVideo` (resources/js/ui/hero-video-dialog.js).

    Every transition is gated on `motion-safe:` and neutralised by
    `motion-reduce:transition-none`, so the lightbox appears instantly for a
    visitor who asked for reduced motion.
--}}
<div
    x-data="uiHeroVideo({
        videoSrc: @js($videoSrc),
        enableSound: @js((bool) $enableSound),
        hasSound: @js((bool) $soundSrc),
    })"
    data-slot="hero-video-dialog"
    {{ $attributes->merge(['class' => 'relative inline-block w-full max-w-3xl']) }}
>
    <x-ui.dialog hooks="{ onOpen: () => startVideo(), onClose: () => stopVideo() }">
        {{-- Trigger: thumbnail card with centered circular play button --}}
        <x-ui.dialog.trigger
            aria-label="{{ __('Play video') }}"
            class="group relative !block aspect-video w-full overflow-hidden rounded-lg border border-border shadow-lg transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background motion-safe:hover:shadow-xl motion-reduce:transition-none"
        >
            @if ($thumbnailSrc)
                <img src="{{ $thumbnailSrc }}" alt="{{ __($thumbnailAlt) }}" class="size-full object-cover" loading="lazy" decoding="async" />
            @else
                {{-- Token placeholder surface with a faint gradient + muted play glyph --}}
                <span class="absolute inset-0 bg-muted bg-gradient-to-br from-muted to-card" aria-hidden="true"></span>
                <span class="absolute inset-0 flex items-center justify-center" aria-hidden="true">
                    <svg viewBox="0 0 24 24" fill="currentColor" class="size-12 text-muted-foreground/40">
                        <path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
                    </svg>
                </span>
            @endif

            {{-- Centered circular play button with an idle decorative ping ring --}}
            <span class="absolute inset-0 flex items-center justify-center" aria-hidden="true">
                <span class="relative flex size-16 items-center justify-center">
                    <span class="absolute inline-flex size-full rounded-full bg-primary/30 motion-safe:animate-ping"></span>
                    {{-- The play glyph's path already sits right of the viewBox centre
                         (its visual centre is at x≈14 of 24), which is the optical
                         correction a triangle needs — no extra margin. --}}
                    <span class="relative inline-flex size-16 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform duration-200 motion-safe:group-hover:scale-110 motion-reduce:transition-none">
                        <svg viewBox="0 0 24 24" fill="currentColor" class="size-7">
                            <path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
                        </svg>
                    </span>
                </span>
            </span>
        </x-ui.dialog.trigger>

        {{-- Lightbox: the dialog panel hosts the video edge to edge. --}}
        <x-ui.dialog.content
            overlay="blur"
            padding="none"
            size="xl"
            aria-label="{{ __($title) }}"
            class="shadow-2xl"
        >
            <div class="relative aspect-video w-full overflow-hidden rounded-lg bg-card">
                {{-- Close button (top end), as a pill that reads over video --}}
                <x-ui.dialog.close class="!rounded-full bg-background/80 text-foreground shadow-md backdrop-blur-sm hover:bg-background" />

                @if ($soundSrc)
                    {{-- Mute / unmute toggle (only when a sound source is provided) --}}
                    <button
                        type="button"
                        x-on:click="toggleMute()"
                        :aria-pressed="muted ? 'true' : 'false'"
                        :aria-label="muted ? @js(__('Unmute')) : @js(__('Mute'))"
                        class="absolute bottom-3 end-3 z-10 inline-flex size-9 items-center justify-center rounded-full bg-background/80 text-foreground shadow-md backdrop-blur-sm transition-colors duration-150 hover:bg-background focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
                    >
                        {{-- Speaker on --}}
                        <svg x-show="!muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5" aria-hidden="true">
                            <path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
                        </svg>
                        {{-- Speaker muted --}}
                        <svg x-show="muted" x-cloak viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5" aria-hidden="true">
                            <path d="M11 5 6 9H2v6h4l5 4V5zM22 9l-6 6M16 9l6 6" />
                        </svg>
                    </button>

                    <audio x-ref="audio" src="{{ $soundSrc }}" preload="none" loop class="sr-only"></audio>
                @endif

                {{-- `src` is set by the dialog's onOpen hook and cleared by onClose,
                     so the video only loads (and plays) while the lightbox is open. --}}
                <iframe
                    :src="activeSrc"
                    title="{{ __($title) }}"
                    allow="autoplay; fullscreen"
                    allowfullscreen
                    class="size-full"
                ></iframe>
            </div>
        </x-ui.dialog.content>
    </x-ui.dialog>
</div>
resources/js/ui/hero-video-dialog.js JS
/**
 * Hero Video Dialog behaviour (immersive thumbnail → modal lightbox).
 *
 * The trigger is a thumbnail card with a centered play button. Activating it
 * opens a modal lightbox containing an embedded <iframe> video. The lightbox
 * is the dialog item (`<x-ui.dialog>` in the Blade): focus management, the
 * Tab trap, Escape/backdrop/close-button dismissal, scroll lock, the inert
 * sweep and focus return are all its job. This component owns the video:
 *  - the dialog's `onOpen` hook calls `startVideo()`, which sets the iframe
 *    `src` (with autoplay) once the panel is laid out, so the video only
 *    loads while the lightbox is open;
 *  - the dialog's `onClose` hook calls `stopVideo()`, which clears the `src`
 *    so playback (and audio) stops and the connection is freed.
 *
 * When a sound source is configured, an <audio> ref is played on open (if sound
 * is enabled) and a mute/unmute toggle controls it. With no sound source the
 * markup omits the audio element and toggle entirely.
 *
 * `init()` adds no global listeners. `destroy()` pauses audio so unmounting
 * mid-playback can't leak sound; the dialog releases its own locks.
 *
 * Self-registers on `alpine:init` so import order does not matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiHeroVideo', (config = {}) => ({
        videoSrc: typeof config.videoSrc === 'string' ? config.videoSrc : '',
        enableSound: config.enableSound ?? true,
        hasSound: Boolean(config.hasSound),
        muted: false,
        activeSrc: '',
        // The <audio> lives inside the teleported dialog panel, so its ref is
        // only reachable from inside the dialog; keep the element once seen so
        // destroy() can still pause it.
        _audio: null,

        init() {
            this.muted = !this.enableSound;
        },

        destroy() {
            // Hygiene: never leave audio playing after an unmount.
            this.stopAudio();
        },

        /** Dialog `onOpen` hook: load the video only while the lightbox is open. */
        startVideo() {
            this.activeSrc = this.withAutoplay(this.videoSrc);
            if (this.hasSound && !this.muted) {
                this.playAudio();
            }
        },

        /** Dialog `onClose` hook: clearing src stops playback and frees the connection. */
        stopVideo() {
            this.stopAudio();
            this.activeSrc = '';
        },

        toggleMute() {
            this.muted = !this.muted;
            const audio = this.audio();
            if (!audio) {
                return;
            }
            if (this.muted) {
                audio.pause();
            } else {
                this.playAudio();
            }
        },

        audio() {
            const audio = this.$refs.audio || this._audio;
            if (audio) {
                this._audio = audio;
            }
            return audio || null;
        },

        playAudio() {
            const audio = this.audio();
            if (!audio) {
                return;
            }
            audio.muted = false;
            const result = audio.play();
            // Autoplay can reject (no user gesture / policy); ignore quietly.
            if (result && typeof result.catch === 'function') {
                result.catch(() => {});
            }
        },

        stopAudio() {
            const audio = this.audio();
            if (!audio) {
                return;
            }
            audio.pause();
            audio.currentTime = 0;
        },

        withAutoplay(src) {
            if (!src) {
                return src;
            }
            const join = src.includes('?') ? '&' : '?';
            return `${src}${join}autoplay=1`;
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.4.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0