Video Embed
Click-to-load video facade: nothing from the video host loads until the visitor presses Play, then a sandboxed iframe replaces the facade and takes focus. Without JavaScript the facade is a plain link to the video.
Preview
Loads content from Brok
{{-- The preview embeds a same-origin page, so nothing third-party loads even after Play. --}}
<div class="w-full max-w-xl">
<x-ui.video-embed
src="{{ url('/og/component/video-embed') }}"
title="{{ __('Product tour: set up your first workspace') }}"
provider="Brok"
/>
</div>
Installation
php artisan ui:add video-embed
Note
This component ships an Alpine behavior module at
resources/js/ui/video-embed.js. Import it once from your bundle so it registers on alpine:init:
import './video-embed.js';
Registry contract
php artisan ui:add video-embed
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.
-
resources/views/components/ui/video-embed.blade.php -
resources/js/ui/video-embed.js
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.
# Brok UI: Video Embed (`video-embed`)
Click-to-load video facade: nothing from the video host loads until the visitor presses Play, then a sandboxed iframe replaces the facade and takes focus. Without JavaScript the facade is a plain link to the video.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add video-embed
```
## Usage
```blade
{{-- The preview embeds a same-origin page, so nothing third-party loads even after Play. --}}
<div class="w-full max-w-xl">
<x-ui.video-embed
src="{{ url('/og/component/video-embed') }}"
title="{{ __('Product tour: set up your first workspace') }}"
provider="Brok"
/>
</div>
```
## Props
- `src` (string, default `null`) — Required embed URL, loaded only after Play. Must be an absolute http(s) URL or a root-relative path; javascript:, data: and other schemes throw.
- `title` (string, default `null`) — The iframe title, shown on the facade and part of the Play control's name. Required unless caption is given; a blank title then takes the caption. Give each video its real name, not a position such as Video 1.
- `caption` (string|null, default `null`) — Optional visible caption under the player. The root becomes a <figure> with the caption as its <figcaption>, and a blank title takes the caption.
- `startLabel` (string, default `Play video`) — Visible label on the Play control.
- `provider` (string|null, default `null`) — Display name of the host (for example YouTube). Renders the notice Loads content from <provider>.
- `poster` (string|null, default `null`) — Optional poster image URL. Use your own copy; a provider thumbnail URL would contact the provider before Play.
- `aspect` (string, default `16/9`) — Width/height ratio such as 16/9, 4 / 3 or 1. Anything else falls back to 16 / 9.
- `sandbox` (string, default `allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox`) — iframe sandbox tokens. Enough for YouTube and Vimeo players (scripts, their own storage, fullscreen presentation, opening the video on the provider site). An empty string drops the attribute.
- `referrerpolicy` (string, default `strict-origin-when-cross-origin`) — iframe referrer policy: the provider sees your origin, not the full page URL.
- `allow` (string, default `autoplay; encrypted-media; picture-in-picture; fullscreen`) — Permissions policy for the iframe: autoplay (the Play click already is the user's choice), encrypted-media (DRM players), picture-in-picture and fullscreen. No sensors, camera, microphone or clipboard. An empty string drops the attribute.
- `allowfullscreen` (bool, default `true`) — Adds the legacy allowfullscreen attribute, but only when allow does not already grant fullscreen: allow takes precedence and browsers warn when both are present. With the default allow it is not rendered.
## Use when
- Use when image or video selection, preview, or media-based comparison is part of the task.
- Embedding a third-party video (YouTube, Vimeo, a hosted player) inline in content without loading the provider before the visitor asks for it.
- Keeping a page fast and free of third-party requests until Play, with a visible notice of which provider the video loads from.
## Avoid when
- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- The video should open in a focused lightbox; use hero-video-dialog.
- A muted decorative background video; use video-mask.
- Your own video file; use a native video element or audio-player style controls instead of an iframe.
## Anti-patterns
- Requiring media-heavy interaction when text is faster
## Rules
- Use the `<brok:video-embed>` tag (or `<x-ui.video-embed>`) 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/video-embed
- Registry JSON (files, props, contract): https://brokui.dev/r/open/video-embed.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
<div class="w-full max-w-xs">
<x-ui.video-embed
src="{{ url('/og/component/video-embed') }}"
title="{{ __('A deliberately long video title that has to wrap inside a narrow facade without pushing the Play control out of the frame: Donaudampfschifffahrtsgesellschaftskapitän') }}"
provider="{{ __('A video host with a long display name') }}"
aspect="4/3"
/>
</div>
With Poster
{{-- A poster from your own origin is fine: it is not a provider request. --}}
<div class="w-full max-w-xl">
<x-ui.video-embed
src="{{ url('/og/component/video-embed') }}"
poster="{{ asset('og-default.png') }}"
title="{{ __('Product tour: set up your first workspace') }}"
start-label="{{ __('Watch the tour') }}"
provider="Brok"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | null | Required embed URL, loaded only after Play. Must be an absolute http(s) URL or a root-relative path; javascript:, data: and other schemes throw. |
| title | string | null | The iframe title, shown on the facade and part of the Play control's name. Required unless caption is given; a blank title then takes the caption. Give each video its real name, not a position such as Video 1. |
| caption | string | null | null | Optional visible caption under the player. The root becomes a <figure> with the caption as its <figcaption>, and a blank title takes the caption. |
| startLabel | string | Play video | Visible label on the Play control. |
| provider | string | null | null | Display name of the host (for example YouTube). Renders the notice Loads content from <provider>. |
| poster | string | null | null | Optional poster image URL. Use your own copy; a provider thumbnail URL would contact the provider before Play. |
| aspect | string | 16/9 | Width/height ratio such as 16/9, 4 / 3 or 1. Anything else falls back to 16 / 9. |
| sandbox | string | allow-scripts allow-same-origin allow-presentati... | iframe sandbox tokens. Enough for YouTube and Vimeo players (scripts, their own storage, fullscreen presentation, opening the video on the provider site). An empty string drops the attribute. |
| referrerpolicy | string | strict-origin-when-cross-origin | iframe referrer policy: the provider sees your origin, not the full page URL. |
| allow | string | autoplay; encrypted-media; picture-in-picture; f... | Permissions policy for the iframe: autoplay (the Play click already is the user's choice), encrypted-media (DRM players), picture-in-picture and fullscreen. No sensors, camera, microphone or clipboard. An empty string drops the attribute. |
| allowfullscreen | bool | true | Adds the legacy allowfullscreen attribute, but only when allow does not already grant fullscreen: allow takes precedence and browsers warn when both are present. With the default allow it is not rendered. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Before Play the component renders no iframe, no provider script and no provider image: the iframe lives in an x-if template.
- The facade is a link to src. With JavaScript it takes role button; a click, Enter or Space inserts the iframe in place, moves focus to it and dispatches a bubbling video-embed-play event with detail { src }. A middle click still opens the link.
- The component never adds autoplay parameters. Add the provider's autoplay parameter to src when one click should also start playback.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Select, preview, or compare visual media.
Use when
- Use when image or video selection, preview, or media-based comparison is part of the task.
- Embedding a third-party video (YouTube, Vimeo, a hosted player) inline in content without loading the provider before the visitor asks for it.
- Keeping a page fast and free of third-party requests until Play, with a visible notice of which provider the video loads from.
Avoid when
- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- The video should open in a focused lightbox; use hero-video-dialog.
- A muted decorative background video; use video-mask.
- Your own video file; use a native video element or audio-player style controls instead of an iframe.
Use instead
- Text or list representation
Anti-patterns
- Requiring media-heavy interaction when text is faster
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Space Tab
- Focus
Focus moves to the iframe after Play.
- The Play control's name is its visible label plus the video title; the iframe carries the same title.
- Focus moves into the iframe after Play, so keyboard users land on the player.
- Without JavaScript the facade is an ordinary link to the video.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="video-embed-{{ $record->id }}">
{{-- The preview embeds a same-origin page, so nothing third-party loads even after Play. --}}
<div class="w-full max-w-xl">
<x-ui.video-embed
src="{{ url('/og/component/video-embed') }}"
title="{{ __('Product tour: set up your first workspace') }}"
provider="Brok"
/>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Embed URL, loaded only after Play. Absolute http(s) or a root-relative path.
'src' => null,
// The iframe title, and part of the Play control's name. Required unless
// `caption` is given: a blank title then takes the caption.
'title' => null,
// Optional visible caption under the player. With a caption the root is a
// <figure> and the caption its <figcaption>.
'caption' => null,
// Visible label on the Play control. Defaults to "Play video".
'startLabel' => 'Play video',
// Display name of the host, shown as "Loads content from <provider>".
'provider' => null,
// Optional poster image URL. Use your own copy: a third-party thumbnail
// URL would load from the provider before the visitor chose to play.
'poster' => null,
// Width / height ratio, for example "16/9", "4 / 3" or "1".
'aspect' => '16/9',
// Iframe defaults. Each one is overridable; pass an empty string to drop
// `sandbox` or `allow` when a provider needs more than the default.
'sandbox' => 'allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox',
'referrerpolicy' => 'strict-origin-when-cross-origin',
'allow' => 'autoplay; encrypted-media; picture-in-picture; fullscreen',
// Legacy attribute. It renders only when `allow` does not already grant
// fullscreen: `allow` takes precedence and browsers warn about both.
'allowfullscreen' => true,
])
@php
$embedUrl = trim((string) $src);
$scheme = strtolower((string) parse_url($embedUrl, PHP_URL_SCHEME));
$isAbsoluteHttp = in_array($scheme, ['http', 'https'], true) && filled(parse_url($embedUrl, PHP_URL_HOST));
$isRootRelative = str_starts_with($embedUrl, '/') && ! str_starts_with($embedUrl, '//') && ! str_contains($embedUrl, '\\');
if (! $isAbsoluteHttp && ! $isRootRelative) {
throw new \InvalidArgumentException('video-embed only accepts an http(s) or root-relative src.');
}
if (blank($title) && filled($caption)) {
$title = $caption;
}
if (blank($title)) {
throw new \InvalidArgumentException('video-embed needs a non-empty title.');
}
$hasCaption = filled($caption);
$rootTag = $hasCaption ? 'figure' : 'div';
$allowsFullscreen = preg_match('/(?:^|;)\s*fullscreen(?:\s|;|$)/i', (string) $allow) === 1;
$label = __(filled($startLabel) ? $startLabel : 'Play video');
$ratio = preg_match('/^\s*\d+(?:\.\d+)?\s*(?:\/\s*\d+(?:\.\d+)?\s*)?$/', (string) $aspect) === 1
? preg_replace('/\s*\/\s*/', ' / ', trim((string) $aspect))
: '16 / 9';
@endphp
<{{ $rootTag }}
x-data="uiVideoEmbed({ src: @js($embedUrl) })"
data-slot="video-embed"
:data-state="playing ? 'playing' : 'idle'"
data-state="idle"
{{ $attributes->merge(['class' => 'flex min-w-0 max-w-full flex-col gap-2']) }}
>
<x-ui.aspect-ratio :ratio="$ratio" class="rounded-lg border border-border bg-muted">
{{-- The facade is a real link, so it still reaches the video without
JavaScript. With JavaScript it becomes the Play button: a primary
click or Space loads the iframe in place; a middle click still
opens the link. --}}
<a
href="{{ $embedUrl }}"
rel="noopener noreferrer"
data-slot="video-embed-play"
x-show="!playing"
x-bind:role="'button'"
@click.prevent="play()"
@keydown.space.prevent="play()"
class="group absolute inset-0 flex flex-col items-center justify-center gap-3 overflow-hidden p-4 text-center text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-inset focus-visible:ring-ring"
>
@if (filled($poster))
<img src="{{ $poster }}" alt="" loading="lazy" decoding="async" data-slot="video-embed-poster" class="absolute inset-0 size-full object-cover" />
<span class="absolute inset-0 bg-gradient-to-t from-background/90 via-background/40 to-transparent" aria-hidden="true"></span>
@endif
<span class="relative inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor" class="size-6">
<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 class="relative flex min-w-0 max-w-full flex-col gap-1">
<span data-slot="video-embed-label" class="text-sm font-medium">{{ $label }}</span>
<span data-slot="video-embed-title" class="line-clamp-2 break-words text-sm text-muted-foreground">{{ $title }}</span>
</span>
</a>
<template x-if="playing">
<iframe
x-ref="frame"
src="{{ $embedUrl }}"
title="{{ $title }}"
data-slot="video-embed-iframe"
@if (filled($sandbox)) sandbox="{{ $sandbox }}" @endif
referrerpolicy="{{ $referrerpolicy }}"
@if (filled($allow)) allow="{{ $allow }}" @endif
loading="lazy"
@if ($allowfullscreen && ! $allowsFullscreen) allowfullscreen @endif
class="absolute inset-0 size-full border-0"
></iframe>
</template>
</x-ui.aspect-ratio>
@if ($hasCaption)
<figcaption data-slot="video-embed-caption" class="break-words text-sm text-muted-foreground">{{ $caption }}</figcaption>
@endif
@if (filled($provider))
<p data-slot="video-embed-notice" class="break-words text-xs text-muted-foreground">
{{ __('Loads content from :provider', ['provider' => $provider]) }}
</p>
@endif
</{{ $rootTag }}>
/**
* Video Embed behaviour: a click-to-load facade.
*
* Nothing from the video host loads before the visitor presses Play: the
* iframe lives in an `x-if` template, so it is not in the document (and its
* `src` is not requested) until `play()` runs. The facade is a real link to
* the video, so it still works without JavaScript.
*
* `play()` inserts the iframe, moves focus to it and dispatches a bubbling
* `video-embed-play` event with `{ src }`. The component adds no global
* listeners, so `destroy()` has nothing to remove.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiVideoEmbed', (config = {}) => ({
src: typeof config.src === 'string' ? config.src : '',
playing: false,
play() {
if (this.playing) {
return;
}
this.playing = true;
this.$nextTick(() => {
this.$refs.frame?.focus();
this.$dispatch('video-embed-play', { src: this.src });
});
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.0 - 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