diff --git a/packages/html/src/define/ui/alert-dialog-close.ts b/packages/html/src/define/ui/alert-dialog-close.ts
new file mode 100644
index 00000000..88b96dcc
--- /dev/null
+++ b/packages/html/src/define/ui/alert-dialog-close.ts
@@ -0,0 +1,10 @@
+import { AlertDialogCloseElement } from '../../ui/alert-dialog/alert-dialog-close-element';
+import { safeDefine } from '../safe-define';
+
+safeDefine(AlertDialogCloseElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [AlertDialogCloseElement.tagName]: AlertDialogCloseElement;
+ }
+}
diff --git a/packages/html/src/define/ui/alert-dialog-description.ts b/packages/html/src/define/ui/alert-dialog-description.ts
new file mode 100644
index 00000000..3f68d38a
--- /dev/null
+++ b/packages/html/src/define/ui/alert-dialog-description.ts
@@ -0,0 +1,10 @@
+import { AlertDialogDescriptionElement } from '../../ui/alert-dialog/alert-dialog-description-element';
+import { safeDefine } from '../safe-define';
+
+safeDefine(AlertDialogDescriptionElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [AlertDialogDescriptionElement.tagName]: AlertDialogDescriptionElement;
+ }
+}
diff --git a/packages/html/src/define/ui/alert-dialog-title.ts b/packages/html/src/define/ui/alert-dialog-title.ts
new file mode 100644
index 00000000..1dd55b86
--- /dev/null
+++ b/packages/html/src/define/ui/alert-dialog-title.ts
@@ -0,0 +1,10 @@
+import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-title-element';
+import { safeDefine } from '../safe-define';
+
+safeDefine(AlertDialogTitleElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [AlertDialogTitleElement.tagName]: AlertDialogTitleElement;
+ }
+}
diff --git a/packages/html/src/define/ui/alert-dialog.ts b/packages/html/src/define/ui/alert-dialog.ts
new file mode 100644
index 00000000..11a0dcfe
--- /dev/null
+++ b/packages/html/src/define/ui/alert-dialog.ts
@@ -0,0 +1,20 @@
+import { AlertDialogCloseElement } from '../../ui/alert-dialog/alert-dialog-close-element';
+import { AlertDialogDescriptionElement } from '../../ui/alert-dialog/alert-dialog-description-element';
+import { AlertDialogElement } from '../../ui/alert-dialog/alert-dialog-element';
+import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-title-element';
+import { safeDefine } from '../safe-define';
+
+// Parent first — child elements consume its context.
+safeDefine(AlertDialogElement);
+safeDefine(AlertDialogCloseElement);
+safeDefine(AlertDialogDescriptionElement);
+safeDefine(AlertDialogTitleElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [AlertDialogElement.tagName]: AlertDialogElement;
+ [AlertDialogCloseElement.tagName]: AlertDialogCloseElement;
+ [AlertDialogDescriptionElement.tagName]: AlertDialogDescriptionElement;
+ [AlertDialogTitleElement.tagName]: AlertDialogTitleElement;
+ }
+}
diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts
index 471fddea..cd2dec5d 100644
--- a/packages/html/src/index.ts
+++ b/packages/html/src/index.ts
@@ -14,6 +14,11 @@ export * from './store/container-mixin';
export * from './store/provider-mixin';
export * from './store/types';
// UI Components
+export { AlertDialogCloseElement } from './ui/alert-dialog/alert-dialog-close-element';
+export { AlertDialogDescriptionElement } from './ui/alert-dialog/alert-dialog-description-element';
+export { AlertDialogElement } from './ui/alert-dialog/alert-dialog-element';
+export { AlertDialogTitleElement } from './ui/alert-dialog/alert-dialog-title-element';
+export { type AlertDialogContextValue, alertDialogContext } from './ui/alert-dialog/context';
export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element';
export { CaptionsButtonElement } from './ui/captions-button/captions-button-element';
export { ControlsElement } from './ui/controls/controls-element';
diff --git a/packages/html/src/ui/alert-dialog/alert-dialog-close-element.ts b/packages/html/src/ui/alert-dialog/alert-dialog-close-element.ts
new file mode 100644
index 00000000..6a98f8a2
--- /dev/null
+++ b/packages/html/src/ui/alert-dialog/alert-dialog-close-element.ts
@@ -0,0 +1,44 @@
+import { applyElementProps, applyStateDataAttrs, createButton } from '@videojs/core/dom';
+import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
+import { ContextConsumer } from '@videojs/element/context';
+
+import { MediaElement } from '../media-element';
+import { alertDialogContext } from './context';
+
+export class AlertDialogCloseElement extends MediaElement {
+ static readonly tagName = 'media-alert-dialog-close';
+
+ static override properties = {
+ disabled: { type: Boolean },
+ } satisfies PropertyDeclarationMap<'disabled'>;
+
+ disabled = false;
+
+ readonly #ctx = new ContextConsumer(this, { context: alertDialogContext, subscribe: true });
+
+ #disconnect: AbortController | null = null;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.#disconnect = new AbortController();
+
+ const buttonProps = createButton({
+ onActivate: () => this.#ctx.value?.close(),
+ isDisabled: () => this.disabled,
+ });
+
+ applyElementProps(this, buttonProps, { signal: this.#disconnect.signal });
+ }
+
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.#disconnect?.abort();
+ this.#disconnect = null;
+ }
+
+ protected override update(_changed: PropertyValues): void {
+ super.update(_changed);
+ const ctx = this.#ctx.value;
+ if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
+ }
+}
diff --git a/packages/html/src/ui/alert-dialog/alert-dialog-description-element.ts b/packages/html/src/ui/alert-dialog/alert-dialog-description-element.ts
new file mode 100644
index 00000000..7c4e7e40
--- /dev/null
+++ b/packages/html/src/ui/alert-dialog/alert-dialog-description-element.ts
@@ -0,0 +1,18 @@
+import type { AlertDialogState } from '@videojs/core';
+import type { PropertyValues } from '@videojs/element';
+import { ContextConsumer } from '@videojs/element/context';
+
+import { ContextPartElement } from '../context-part-element';
+import { alertDialogContext } from './context';
+
+export class AlertDialogDescriptionElement extends ContextPartElement {
+ static readonly tagName = 'media-alert-dialog-description';
+
+ protected readonly consumer = new ContextConsumer(this, { context: alertDialogContext, subscribe: true });
+
+ protected override update(changed: PropertyValues): void {
+ super.update(changed);
+ const descriptionId = this.consumer.value?.state.descriptionId;
+ if (descriptionId) this.id = descriptionId;
+ }
+}
diff --git a/packages/html/src/ui/alert-dialog/alert-dialog-element.ts b/packages/html/src/ui/alert-dialog/alert-dialog-element.ts
new file mode 100644
index 00000000..3688be73
--- /dev/null
+++ b/packages/html/src/ui/alert-dialog/alert-dialog-element.ts
@@ -0,0 +1,101 @@
+import { AlertDialogCore, AlertDialogDataAttrs, type AlertDialogInput } from '@videojs/core';
+import {
+ type AlertDialogApi,
+ applyElementProps,
+ applyStateDataAttrs,
+ createAlertDialog,
+ createTransition,
+} from '@videojs/core/dom';
+import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
+import { ContextProvider } from '@videojs/element/context';
+import { SnapshotController } from '@videojs/store/html';
+
+import { MediaElement } from '../media-element';
+import { alertDialogContext } from './context';
+
+let idCounter = 0;
+
+export class AlertDialogElement extends MediaElement {
+ static readonly tagName = 'media-alert-dialog';
+
+ static override properties = {
+ open: { type: Boolean },
+ } satisfies PropertyDeclarationMap<'open'>;
+
+ open = false;
+
+ readonly #core = new AlertDialogCore();
+ readonly #provider = new ContextProvider(this, { context: alertDialogContext });
+ readonly #titleId = `vjs-alert-dialog-title-${idCounter++}`;
+ readonly #descriptionId = `vjs-alert-dialog-desc-${idCounter++}`;
+
+ #dialog: AlertDialogApi | null = null;
+ #snapshot: SnapshotController | null = null;
+
+ constructor() {
+ super();
+ this.#core.setTitleId(this.#titleId);
+ this.#core.setDescriptionId(this.#descriptionId);
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+
+ this.#dialog = createAlertDialog({
+ transition: createTransition(),
+ onOpenChange: (nextOpen: boolean) => {
+ this.open = nextOpen;
+ this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen } }));
+ },
+ });
+
+ // Register self as the dialog element.
+ this.#dialog.setElement(this);
+
+ if (this.#snapshot) {
+ this.#snapshot.track(this.#dialog.input);
+ } else {
+ this.#snapshot = new SnapshotController(this, this.#dialog.input);
+ }
+ }
+
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.#dialog?.destroy();
+ this.#dialog = null;
+ }
+
+ protected override willUpdate(changed: PropertyValues): void {
+ super.willUpdate(changed);
+
+ // Sync controlled open state.
+ if (this.#dialog && changed.has('open')) {
+ const { active: inputOpen } = this.#dialog.input.current;
+ if (this.open !== inputOpen) {
+ if (this.open) {
+ this.#dialog.open();
+ } else {
+ this.#dialog.close();
+ }
+ }
+ }
+ }
+
+ protected override update(_changed: PropertyValues): void {
+ super.update(_changed);
+ if (!this.#dialog) return;
+
+ const input = this.#dialog.input.current;
+ this.#core.setInput(input);
+ const state = this.#core.getState();
+
+ applyElementProps(this, this.#core.getAttrs(state));
+ applyStateDataAttrs(this, state, AlertDialogDataAttrs);
+
+ this.#provider.setValue({
+ state,
+ stateAttrMap: AlertDialogDataAttrs,
+ close: () => this.#dialog?.close(),
+ });
+ }
+}
diff --git a/packages/html/src/ui/alert-dialog/alert-dialog-title-element.ts b/packages/html/src/ui/alert-dialog/alert-dialog-title-element.ts
new file mode 100644
index 00000000..ebc72fdc
--- /dev/null
+++ b/packages/html/src/ui/alert-dialog/alert-dialog-title-element.ts
@@ -0,0 +1,18 @@
+import type { AlertDialogState } from '@videojs/core';
+import type { PropertyValues } from '@videojs/element';
+import { ContextConsumer } from '@videojs/element/context';
+
+import { ContextPartElement } from '../context-part-element';
+import { alertDialogContext } from './context';
+
+export class AlertDialogTitleElement extends ContextPartElement {
+ static readonly tagName = 'media-alert-dialog-title';
+
+ protected readonly consumer = new ContextConsumer(this, { context: alertDialogContext, subscribe: true });
+
+ protected override update(changed: PropertyValues): void {
+ super.update(changed);
+ const titleId = this.consumer.value?.state.titleId;
+ if (titleId) this.id = titleId;
+ }
+}
diff --git a/packages/html/src/ui/alert-dialog/context.ts b/packages/html/src/ui/alert-dialog/context.ts
new file mode 100644
index 00000000..6db0eac6
--- /dev/null
+++ b/packages/html/src/ui/alert-dialog/context.ts
@@ -0,0 +1,12 @@
+import type { AlertDialogState, StateAttrMap } from '@videojs/core';
+import { createContext } from '@videojs/element/context';
+
+export interface AlertDialogContextValue {
+ state: AlertDialogState;
+ stateAttrMap: StateAttrMap;
+ close: () => void;
+}
+
+const ALERT_DIALOG_CONTEXT_KEY = Symbol('@videojs/alert-dialog');
+
+export const alertDialogContext = createContext(ALERT_DIALOG_CONTEXT_KEY);
diff --git a/packages/html/src/ui/alert-dialog/tests/alert-dialog-element.test.ts b/packages/html/src/ui/alert-dialog/tests/alert-dialog-element.test.ts
new file mode 100644
index 00000000..89089436
--- /dev/null
+++ b/packages/html/src/ui/alert-dialog/tests/alert-dialog-element.test.ts
@@ -0,0 +1,186 @@
+import { flush } from '@videojs/store';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { AlertDialogElement } from '../alert-dialog-element';
+
+let tagCounter = 0;
+
+function uniqueTag(base: string): string {
+ return `${base}-${tagCounter++}`;
+}
+
+function createElement(Base: abstract new () => Element): Element {
+ const tag = uniqueTag('test-el');
+ customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
+ return document.createElement(tag) as Element;
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('AlertDialogElement', () => {
+ it('has the correct tag name', () => {
+ expect(AlertDialogElement.tagName).toBe('media-alert-dialog');
+ });
+
+ it('initializes with open set to false', () => {
+ const el = createElement(AlertDialogElement);
+ expect(el.open).toBe(false);
+ });
+
+ it('sets data-open attribute when open is true', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+
+ expect(el.hasAttribute('data-open')).toBe(true);
+ });
+
+ it('does not set data-open attribute when open is false', async () => {
+ const el = createElement(AlertDialogElement);
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+
+ expect(el.hasAttribute('data-open')).toBe(false);
+ });
+
+ it('removes data-open attribute after close transition completes', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+ expect(el.hasAttribute('data-open')).toBe(true);
+
+ el.open = false;
+ await el.updateComplete;
+
+ // data-open stays true during the ending transition (active: true, status: 'ending').
+ // Wait for the close transition to fully complete (double RAF + animation wait).
+ await vi.waitFor(() => {
+ expect(el.hasAttribute('data-open')).toBe(false);
+ });
+ });
+
+ it('applies alertdialog role and aria-modal', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+
+ expect(el.getAttribute('role')).toBe('alertdialog');
+ expect(el.getAttribute('aria-modal')).toBe('true');
+ });
+
+ it('dispatches open-change event on close', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+ flush();
+
+ const spy = vi.fn();
+ el.addEventListener('open-change', spy);
+
+ // Escape triggers dismiss layer → onOpenChange(false) → open-change event.
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+
+ expect(el.open).toBe(false);
+ expect(spy).toHaveBeenCalledOnce();
+ expect((spy.mock.calls[0]![0] as CustomEvent).detail).toEqual({ open: false });
+ });
+
+ it('closes on Escape key press', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+ flush();
+
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+
+ expect(el.open).toBe(false);
+ });
+
+ it('does not close on Escape when already closed', async () => {
+ const el = createElement(AlertDialogElement);
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+
+ const spy = vi.fn();
+ el.addEventListener('open-change', spy);
+
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+
+ expect(el.open).toBe(false);
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('ignores non-Escape key presses', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+ flush();
+
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+
+ expect(el.open).toBe(true);
+ });
+
+ it('closes on button click within the dialog', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ const button = document.createElement('button');
+ el.appendChild(button);
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+ flush();
+
+ button.click();
+
+ expect(el.open).toBe(false);
+ });
+
+ it('does not close on non-button element click', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ const span = document.createElement('span');
+ el.appendChild(span);
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+ flush();
+
+ span.click();
+
+ expect(el.open).toBe(true);
+ });
+
+ it('cleans up on disconnect', async () => {
+ const el = createElement(AlertDialogElement);
+ el.open = true;
+
+ document.body.appendChild(el);
+ await el.updateComplete;
+ flush();
+
+ document.body.removeChild(el);
+
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+
+ // Dialog was destroyed on disconnect, so open should still be true.
+ expect(el.open).toBe(true);
+ });
+});
diff --git a/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx b/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx
index a0783b12..a666a84c 100644
--- a/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx
+++ b/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx
@@ -10,12 +10,15 @@ import { useAlertDialogContext } from './context';
export interface AlertDialogCloseProps extends UIComponentProps<'button', AlertDialogCore.State> {}
export const AlertDialogClose = forwardRef(function AlertDialogClose(
- { render, className, style, ...elementProps },
+ { render, className, style, disabled, ...elementProps },
forwardedRef
) {
const { dialog, state } = useAlertDialogContext();
- const handleClick = useCallback(() => dialog.close(), [dialog]);
+ const handleClick = useCallback(() => {
+ if (disabled) return;
+ dialog.close();
+ }, [dialog, disabled]);
return renderElement(
'button',
@@ -23,7 +26,7 @@ export const AlertDialogClose = forwardRef