diff --git a/packages/html/src/define/ui/popover.ts b/packages/html/src/define/ui/popover.ts
new file mode 100644
index 00000000..9bdb9cdb
--- /dev/null
+++ b/packages/html/src/define/ui/popover.ts
@@ -0,0 +1,9 @@
+import { PopoverElement } from '../../ui/popover/popover-element';
+
+customElements.define(PopoverElement.tagName, PopoverElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [PopoverElement.tagName]: PopoverElement;
+ }
+}
diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts
index a6d98d8c..55e4cf9c 100644
--- a/packages/html/src/index.ts
+++ b/packages/html/src/index.ts
@@ -23,6 +23,7 @@ export { MuteButtonElement } from './ui/mute-button/mute-button-element';
export { PiPButtonElement } from './ui/pip-button/pip-button-element';
export { PlayButtonElement } from './ui/play-button/play-button-element';
export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-rate-button-element';
+export { PopoverElement } from './ui/popover/popover-element';
export { PosterElement } from './ui/poster/poster-element';
export { SeekButtonElement } from './ui/seek-button/seek-button-element';
export { ThumbnailElement } from './ui/thumbnail/thumbnail-element';
diff --git a/packages/html/src/ui/popover/popover-element.ts b/packages/html/src/ui/popover/popover-element.ts
new file mode 100644
index 00000000..041a4041
--- /dev/null
+++ b/packages/html/src/ui/popover/popover-element.ts
@@ -0,0 +1,201 @@
+import { PopoverCore, PopoverDataAttrs, type PopoverInteraction, type PopoverProps } from '@videojs/core';
+import {
+ applyElementProps,
+ applyStateDataAttrs,
+ createPopover,
+ createTransitionHandler,
+ getAnchorNameStyle,
+ getAnchorPositionStyle,
+ type PopoverChangeDetails,
+ type PopoverHandle,
+ resolveOffsets,
+} from '@videojs/core/dom';
+import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
+import { SnapshotController } from '@videojs/store/html';
+import { applyStyles, supportsAnchorPositioning } from '@videojs/utils/dom';
+
+import { MediaElement } from '../media-element';
+
+export class PopoverElement extends MediaElement {
+ static readonly tagName = 'media-popover';
+
+ static override properties = {
+ open: { type: Boolean },
+ defaultOpen: { type: Boolean, attribute: 'default-open' },
+ side: { type: String },
+ align: { type: String },
+ modal: { type: Boolean },
+ closeOnEscape: { type: Boolean, attribute: 'close-on-escape' },
+ closeOnOutsideClick: { type: Boolean, attribute: 'close-on-outside-click' },
+ openOnHover: { type: Boolean, attribute: 'open-on-hover' },
+ delay: { type: Number },
+ closeDelay: { type: Number, attribute: 'close-delay' },
+ } satisfies PropertyDeclarationMap;
+
+ open = PopoverCore.defaultProps.open;
+ defaultOpen = PopoverCore.defaultProps.defaultOpen;
+ side = PopoverCore.defaultProps.side;
+ align = PopoverCore.defaultProps.align;
+ modal: PopoverProps['modal'] = PopoverCore.defaultProps.modal;
+ closeOnEscape = PopoverCore.defaultProps.closeOnEscape;
+ closeOnOutsideClick = PopoverCore.defaultProps.closeOnOutsideClick;
+ openOnHover = PopoverCore.defaultProps.openOnHover;
+ delay = PopoverCore.defaultProps.delay;
+ closeDelay = PopoverCore.defaultProps.closeDelay;
+
+ readonly #core = new PopoverCore();
+ #popover: PopoverHandle | null = null;
+ #snapshot: SnapshotController | null = null;
+
+ // Cleanup controllers
+ #disconnect: AbortController | null = null;
+ #triggerAc: AbortController | null = null;
+ #currentTrigger: HTMLElement | null = null;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.#disconnect = new AbortController();
+
+ this.#popover = createPopover({
+ transition: createTransitionHandler(),
+ onOpenChange: (nextOpen: boolean, details: PopoverChangeDetails) => {
+ this.open = nextOpen;
+ this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen, ...details } }));
+ },
+ closeOnEscape: () => this.closeOnEscape,
+ closeOnOutsideClick: () => this.closeOnOutsideClick,
+ openOnHover: () => this.openOnHover,
+ delay: () => this.delay,
+ closeDelay: () => this.closeDelay,
+ });
+
+ // Register self as the popup element — the element IS the popup.
+ this.#popover.setPopupElement(this);
+
+ // Apply popup event handlers (pointerenter/leave, focusout) to self.
+ applyElementProps(this, this.#popover.popupProps, this.#disconnect.signal);
+
+ // Subscribe to interaction state for reactive updates.
+ // Reuse the controller across connect/disconnect cycles to avoid
+ // leaking stale controllers in the host's controller set.
+ if (this.#snapshot) {
+ this.#snapshot.track(this.#popover.interaction);
+ } else {
+ this.#snapshot = new SnapshotController(this, this.#popover.interaction);
+ }
+ }
+
+ protected override firstUpdated(changed: PropertyValues): void {
+ super.firstUpdated(changed);
+
+ // Uncontrolled mode: open if `defaultOpen` is set. Controlled `open`
+ // is already synced by `willUpdate` on the first render cycle.
+ if (this.defaultOpen && !this.open) {
+ this.#popover?.open();
+ }
+ }
+
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.#cleanupTrigger();
+ this.#popover?.destroy();
+ this.#popover = null;
+ this.#disconnect?.abort();
+ this.#disconnect = null;
+ }
+
+ protected override willUpdate(changed: PropertyValues): void {
+ super.willUpdate(changed);
+ this.#core.setProps(this);
+
+ // Sync controlled open state
+ if (this.#popover && changed.has('open')) {
+ const { active: interactionOpen } = this.#popover.interaction.current;
+ if (this.open !== interactionOpen) {
+ if (this.open) {
+ this.#popover.open();
+ } else {
+ this.#popover.close();
+ }
+ }
+ }
+ }
+
+ protected override update(_changed: PropertyValues): void {
+ super.update(_changed);
+ if (!this.#popover) return;
+
+ // Discover trigger via commandfor linkage.
+ const triggerEl = this.#findTrigger();
+ this.#syncTrigger(triggerEl);
+
+ // Derive state from core + interaction.
+ const interaction = this.#popover.interaction.current;
+ const state = this.#core.getState(interaction);
+
+ // Apply popup ARIA and data attributes to self.
+ applyElementProps(this, this.#core.getPopupAttrs(state));
+ applyStateDataAttrs(this, state, PopoverDataAttrs);
+
+ // Apply trigger ARIA and anchor-name to the discovered trigger.
+ if (this.#currentTrigger) {
+ applyElementProps(this.#currentTrigger, this.#core.getTriggerAttrs(state, this.id));
+ applyStyles(this.#currentTrigger, getAnchorNameStyle(this.id));
+ }
+
+ // Skip positioning when closed — no rects to measure.
+ if (!state.open) return;
+
+ // Apply positioning styles to self.
+ const posOpts = { side: state.side, align: state.align };
+
+ if (supportsAnchorPositioning()) {
+ // Native CSS Anchor Positioning — no JS rect measurements needed.
+ applyStyles(this, getAnchorPositionStyle(this.id, posOpts));
+ } else {
+ // JS fallback: measure rects and resolve CSS var offsets.
+ const triggerRect = this.#currentTrigger?.getBoundingClientRect();
+ const selfRect = this.getBoundingClientRect();
+ const boundaryRect = document.documentElement.getBoundingClientRect();
+ const offsets = resolveOffsets(this);
+ applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets));
+ }
+ }
+
+ // --- Trigger discovery ---
+
+ #findTrigger(): HTMLElement | null {
+ if (!this.id) return null;
+ const root = this.getRootNode() as Document | ShadowRoot;
+ return root.querySelector(`[commandfor="${this.id}"]`);
+ }
+
+ #syncTrigger(triggerEl: HTMLElement | null): void {
+ if (triggerEl === this.#currentTrigger) return;
+
+ this.#cleanupTrigger();
+ this.#currentTrigger = triggerEl;
+ this.#popover?.setTriggerElement(triggerEl);
+
+ if (triggerEl && this.#popover) {
+ this.#triggerAc = new AbortController();
+ applyElementProps(triggerEl, this.#popover.triggerProps, this.#triggerAc.signal);
+ }
+ }
+
+ #cleanupTrigger(): void {
+ if (this.#currentTrigger) {
+ // Remove ARIA attributes and anchor-name style from the old trigger.
+ applyElementProps(this.#currentTrigger, {
+ 'aria-expanded': undefined,
+ 'aria-haspopup': undefined,
+ 'aria-controls': undefined,
+ });
+ this.#currentTrigger.style.removeProperty('anchor-name');
+ }
+
+ this.#triggerAc?.abort();
+ this.#triggerAc = null;
+ this.#currentTrigger = null;
+ }
+}