Files
v10/internal/design/ui/menus.md
T

32 KiB
Raw Blame History

status, date
status date
draft 2026-04-13

Menu

Compound, headless menu components for media controls — settings, option selection, and context actions.

Problem

Video players need menus for three core interactions:

  1. Settings — quality, playback speed, captions, audio tracks
  2. Option selection — single-choice (radio) and multi-choice (checkbox) groups
  3. Context actions — copy link, report, stats

The dominant pattern for settings menus in video players is in-place navigation: clicking a settings category slides the view to a submenu, and clicking back returns to the root. This is what YouTube, Plyr, and most native player UIs do. Flyout (side-opening) submenus are a valid extension point but not the starting design.

Requirements:

  • Compound and composable — users assemble parts, omit what they don't need
  • Headless — no baked-in styles; CSS custom properties for animation values
  • Accessible — role="menu", full keyboard support, roving tabindex, type-ahead
  • Cascading submenus — in-place slide transitions with animated container resize
  • Treeshakeable — parts only imported when used
  • Cross-platform — same core logic drives React components and HTML custom elements

API

React

Submenus are expressed by nesting Menu.Root inside Menu.Content. There are no separate SubMenu* parts — the same three structural parts (Root, Trigger, Content) compose at every level. A nested Root detects its parent context and behaves as a submenu automatically.

For the in-place settings-menu pattern, the root list is wrapped in Menu.View. Menu.View is not a generic submenu requirement; it marks the root logical view inside a shared viewport so CSS can animate between the root view and nested Menu.Content views. Traditional flyout submenus would use positioned nested content instead and would not require Menu.View.

import { Menu } from '@videojs/react';

<Menu.Root>
  <Menu.Trigger>Settings</Menu.Trigger>
  <Menu.Content>

    <Menu.View>
      <Menu.Root>
        <Menu.Trigger>Quality</Menu.Trigger>
        <Menu.Content>
          <Menu.Back />
          <Menu.RadioGroup value={quality} onValueChange={setQuality}>
            <Menu.GroupLabel>Quality</Menu.GroupLabel>
            <Menu.RadioItem value="auto">Auto</Menu.RadioItem>
            <Menu.RadioItem value="1080p">1080p</Menu.RadioItem>
            <Menu.RadioItem value="720p">720p</Menu.RadioItem>
          </Menu.RadioGroup>
        </Menu.Content>
      </Menu.Root>

      <Menu.Root>
        <Menu.Trigger>Speed</Menu.Trigger>
        <Menu.Content>
          <Menu.Back />
          <Menu.RadioGroup value={speed} onValueChange={setSpeed}>
            <Menu.GroupLabel>Speed</Menu.GroupLabel>
            <Menu.RadioItem value="0.5">0.5×</Menu.RadioItem>
            <Menu.RadioItem value="1">Normal</Menu.RadioItem>
            <Menu.RadioItem value="2">2×</Menu.RadioItem>
          </Menu.RadioGroup>
        </Menu.Content>
      </Menu.Root>

      <Menu.Separator />
      <Menu.Item onSelect={copyLink}>Copy Link</Menu.Item>
    </Menu.View>

  </Menu.Content>
</Menu.Root>

HTML

Submenus are nested <media-menu> elements. A <media-menu-item> with commandfor links to its target submenu by ID — consistent with how other floating components use the invoker API.

For the in-place settings-menu pattern, the root list is wrapped in <media-menu-view>. Like Menu.View, it is a shared-viewport view-navigation boundary, not a requirement for future flyout submenu rendering.

import '@videojs/html/ui/menu';
<button commandfor="settings-menu">Settings</button>
<media-menu id="settings-menu" side="top" align="end">

  <media-menu-view>
    <media-menu-item commandfor="quality-menu">Quality</media-menu-item>
    <media-menu-item commandfor="speed-menu">Speed</media-menu-item>
    <media-menu-separator></media-menu-separator>
    <media-menu-item>Copy Link</media-menu-item>
  </media-menu-view>

  <media-menu id="quality-menu">
    <media-menu-back></media-menu-back>
    <media-menu-radio-group value="auto">
      <media-menu-group-label>Quality</media-menu-group-label>
      <media-menu-radio-item value="auto">Auto</media-menu-radio-item>
      <media-menu-radio-item value="1080p">1080p</media-menu-radio-item>
      <media-menu-radio-item value="720p">720p</media-menu-radio-item>
    </media-menu-radio-group>
  </media-menu>

  <media-menu id="speed-menu">
    <media-menu-back></media-menu-back>
    <media-menu-radio-group value="1">
      <media-menu-group-label>Speed</media-menu-group-label>
      <media-menu-radio-item value="0.5">0.5×</media-menu-radio-item>
      <media-menu-radio-item value="1">Normal</media-menu-radio-item>
      <media-menu-radio-item value="2">2×</media-menu-radio-item>
    </media-menu-radio-group>
  </media-menu>

</media-menu>

Parts

All parts are exported under Menu.* (React) or as <media-menu-*> elements (HTML). One import gives access to everything:

import { Menu } from '@videojs/react';
// Menu.Root, Menu.Trigger, Menu.Content, Menu.Back,
// Menu.Item, Menu.GroupLabel, Menu.Separator, Menu.Group,
// Menu.RadioGroup, Menu.RadioItem, Menu.CheckboxItem, Menu.ItemIndicator

Root

Context provider. Owns menu state and creates the underlying createMenu() instance. When nested inside another Menu.Content, it automatically behaves as a submenu — its Trigger registers as a navigable item in the parent menu, and its Content renders as an in-place submenu view.

Does not render a DOM element in React. In HTML, <media-menu> serves as both Root and Content.

Props:

Prop Type Default Description
open boolean Controlled open state.
defaultOpen boolean false Initial open state (uncontrolled).
side PopoverSide 'bottom' Which side of the trigger the menu appears on. Root menus only — submenus inherit the parent viewport.
align PopoverAlign 'start' Alignment along the trigger's edge. Root menus only.
closeOnEscape boolean true Close the menu when Escape is pressed at root level.
closeOnOutsideClick boolean true Close the menu when clicking outside. Root menus only.
onOpenChange (open: boolean) => void Fired when the menu opens or closes.

Trigger

Opens and closes the menu. At the root level, renders as a standalone button outside Content. When inside a parent Menu.Content (i.e., the Root is a submenu), renders as a menuitem within the parent's navigation — pushing the submenu on activate and registering with the parent's roving tabindex.

ARIA (automatic):

Attribute Value
aria-haspopup "menu"
aria-expanded "true" when open, "false" when closed
aria-controls ID of the associated Content element

When acting as a submenu trigger inside a parent menu: role="menuitem", roving tabIndex, aria-disabled. Also receives data-item and data-highlighted (see items below).

Props:

Prop Type Description
disabled boolean Disables the trigger. Only meaningful when used as a submenu trigger inside a parent menu.
render RenderProp<MenuState> Custom render element.

Content

Popup container. At the root level, handles popover positioning and dismiss behavior. When paired with Menu.View / <media-menu-view>, it also acts as the shared viewport for in-place menu view navigation — only one logical view is active at a time.

When nested in the current in-place navigation mode, it does not use popover positioning. Instead it renders in-place as a generic menu view inside the parent Content's viewport. Gets data-submenu to distinguish it from the root Content. Future flyout submenu support would use positioned nested content and would not require the root Menu.View boundary.

ARIA (automatic):

Attribute Value
role "menu"
tabIndex -1
popover "manual" (root only)

Data attributes — set on Content and inherited by all children:

Attribute Values When
data-open present/absent Menu is open
data-starting-style present/absent Open transition in progress
data-ending-style present/absent Close transition in progress
data-side top / bottom / left / right Popover side (root only)
data-align start / center / end Popover alignment (root only)
data-submenu present/absent This Content belongs to a nested submenu
data-menu-viewport present Root Content is the viewport for menu view transitions

CSS custom properties (set by JS on the root Content during submenu transitions):

Property Description
--media-menu-width Width of the incoming view
--media-menu-height Height of the incoming view
--media-menu-available-height Viewport-constrained max height

HTML events (<media-menu> dispatches, all bubble):

Event Detail Fires when
open-change { open: boolean } Menu opens or closes

View

Root menu view inside Content for in-place view navigation. This is the root panel in the shared menu viewport; CSS decides whether it slides, fades, crossfades, scales, or remains static while child views enter.

Menu.View / <media-menu-view> is only needed when the root menu and nested Content views share one viewport and transition in-place. It is not part of the flat menu API, and it should not be needed for a traditional side-opening flyout submenu pattern.

Data attributes:

Attribute Values When
data-menu-root-view present Marks the root list view
data-menu-view present Marks this element as a menu view
data-menu-view-state active / inactive Root view state within the viewport

Back

Button that navigates back to the parent view. Placed at the top of a submenu Content. Not rendered (or disabled) when already at root depth.

Props:

Prop Type Default Description
label string 'Back' Accessible label (aria-label).
render RenderProp<MenuState> Custom render element.

ARIA (automatic): aria-label from label prop.

Behavior:

  • Click pops the navigation stack.
  • ArrowLeft anywhere in the submenu also pops (handled by Content).
  • After pop, focus returns to the Trigger that navigated forward.

Item

Standard menu item for actions. Activating fires onSelect and closes the menu.

Props:

Prop Type Default Description
disabled boolean false Disables the item.
onSelect () => void Fired on click, Enter, or Space.
render RenderProp<MenuItemState> Custom render element.

ARIA (automatic): role="menuitem", roving tabIndex, aria-disabled.

Data attributes: data-item, data-highlighted. Use [aria-disabled="true"] in CSS for disabled styling.


GroupLabel

Non-interactive heading within a group. Not keyboard-navigable.

Props: render.

ARIA (automatic): Provides an id and registers it with the nearest Group or RadioGroup. The group receives aria-labelledby unless the consumer passed aria-label or aria-labelledby directly.


Separator

Visual divider between groups or items. Not focusable.

ARIA (automatic): role="separator".


Group

Groups related items for assistive technology.

Props:

Prop Type Description
render RenderProp<MenuState> Custom render element.

ARIA (automatic): role="group", aria-labelledby from child GroupLabel when present. Consumers may pass aria-label or aria-labelledby directly.


RadioGroup

Single-selection group. Manages value state — controlled or uncontrolled. In a submenu, selecting a RadioItem automatically navigates back to the parent view (matches YouTube/Plyr behavior).

Props:

Prop Type Default Description
value string Controlled selected value.
defaultValue string Initial value (uncontrolled).
onValueChange (value: string) => void Fired when selection changes.
render RenderProp<MenuState> Custom render element.

ARIA (automatic): role="group", aria-labelledby from child GroupLabel when present. Consumers may pass aria-label or aria-labelledby directly.


RadioItem

Item within a RadioGroup. Represents one selectable option.

Props:

Prop Type Default Description
value string Value this item represents.
disabled boolean false Disables the item.
render RenderProp<MenuRadioItemState> Custom render element.

ARIA (automatic): role="menuitemradio", aria-checked, roving tabIndex, aria-disabled.

Data attributes: data-item, data-highlighted. Use [aria-checked="true"] and [aria-disabled="true"] in CSS for checked and disabled styling.

Behavior: In a submenu, selecting a RadioItem auto-pops back to the parent view after calling onValueChange.

CheckboxItem

Toggle item with independent checked/unchecked state.

Props:

Prop Type Default Description
checked boolean Controlled checked state.
defaultChecked boolean false Initial checked state (uncontrolled).
onCheckedChange (checked: boolean) => void Fired when state toggles.
disabled boolean false Disables the item.
render RenderProp<MenuCheckboxItemState> Custom render element.

ARIA (automatic): role="menuitemcheckbox", aria-checked, roving tabIndex, aria-disabled.

Data attributes: data-item, data-highlighted. Use [aria-checked="true"] and [aria-disabled="true"] in CSS for checked and disabled styling.


ItemIndicator

Visual indicator that renders when the parent RadioItem or CheckboxItem is checked. Use for checkmarks, dots, or icons.

Props: render.

Behavior: Reads checked state from the nearest parent item context. Hidden from assistive technology — the parent item's aria-checked provides the semantic.


HTML element tags

Part Tag
Root / Content <media-menu>
View <media-menu-view>
Back <media-menu-back>
Item <media-menu-item>
GroupLabel <media-menu-group-label>
Separator <media-menu-separator>
Group <media-menu-group>
RadioGroup <media-menu-radio-group>
RadioItem <media-menu-radio-item>
CheckboxItem <media-menu-checkbox-item>
ItemIndicator <media-menu-item-indicator>

In-place submenus are expressed by nesting sibling <media-menu> elements in the parent <media-menu> and linking triggers via commandfor. The root list lives in <media-menu-view> so it can participate in the same view lifecycle as child submenu content.

Navigation model

For in-place navigation, Content acts as a fixed-size viewport. Only one logical view is active at a time: the root list or one submenu's content. Navigation is modelled as a stack:

type StackEntry = {
  /** ID of the nested Menu.Root (submenu) that was pushed. */
  menuId: string;
  /** ID of the Trigger element that initiated the push, for focus restoration. */
  triggerId: string;
};

type NavigationState = {
  stack: StackEntry[];
  direction: 'forward' | 'back' | null;
  exitingMenuId: string | null;
  transitioning: boolean;
};

Push (forward)

  1. User clicks/activates a submenu Trigger (or presses ArrowRight on it).
  2. { menuId, triggerId } is pushed onto the stack.
  3. Both the outgoing view and the incoming submenu Content are in the DOM simultaneously.
  4. First RAF: incoming Content is measured. --media-menu-width and --media-menu-height are set on the root Content.
  5. Second RAF: browser has painted the "from" state. CSS transitions animate the container resize; CSS animations slide the views.
  6. getAnimations() on root Content settles — transition complete.
  7. exitingMenuId is cleared. Only the active view remains.
  8. Focus moves to the first item in the new submenu Content.

Pop (back)

  1. User clicks Back, presses ArrowLeft, or presses Escape while in a submenu.
  2. stack.pop(). Direction set to 'back'.
  3. Same double-RAF + animation settle lifecycle as push, views slide in reverse.
  4. Focus returns to the Trigger identified by the popped entry's triggerId.

Reset

On menu close, the stack resets to [] immediately — no animation plays. The popover's own close animation covers the visual exit. When the menu re-opens, it starts at the root view.

Auto-back on RadioItem selection

When a RadioItem inside a submenu Content is activated, the menu automatically pops back after calling onValueChange. This matches the expected YouTube/Plyr behavior — select an option, return to settings root.

Rapid navigation

If the user navigates while a transition is in progress, the current transition is cancelled (skip to end state) and the new transition starts immediately. Same cancel pattern as createTransition().

Nesting depth

The stack supports arbitrary depth. A submenu Content can contain another nested Menu.Root, creating multi-level paths (Settings → Quality → Advanced). The stack grows and shrinks accordingly.

CSS animation

Animation is driven by data attributes and CSS custom properties. Core may set sizing CSS variables and temporary measurement styles internally; sandbox/user-authored motion styling stays in CSS.

Data attributes

On root Content (and inherited by all children):

Attribute Values When
data-open present/absent Menu is open
data-starting-style present/absent Open transition in progress
data-ending-style present/absent Close transition in progress
data-side top / bottom / left / right Popover side
data-align start / center / end Popover alignment

On submenu Content:

Attribute Values When
data-submenu present Always — identifies this as a submenu view
data-menu-view present Marks this element as a menu view
data-menu-view-state active / inactive This view is entering/current or exiting/hidden
data-open present/absent This view is mounted for transition or active
data-starting-style present/absent View is entering
data-ending-style present/absent View is exiting
data-direction forward / back Direction of the transition

On items (Item, RadioItem, CheckboxItem, and Trigger when used as a submenu trigger):

Attribute Values When
data-item present Always — shared marker across all navigable item types
data-highlighted present/absent Item has keyboard or pointer focus

aria-checked and aria-disabled are set by the component and should be used directly as CSS selectors — no redundant data attributes.

CSS custom properties

Set by JS on the root Content before each submenu transition:

Property Example Description
--media-menu-width 240px Width of the incoming view
--media-menu-height 320px Height of the incoming view
--media-menu-available-height 480px Viewport-constrained max height

Example CSS

/* Container resizes to match the incoming submenu view */
media-menu {
  width: var(--media-menu-width);
  height: var(--media-menu-height);
  max-height: var(--media-menu-available-height, none);
  overflow: hidden;
  transition:
    width 200ms ease,
    height 200ms ease;
}

/* Menu open/close — fade + slight scale */
media-menu[data-starting-style],
media-menu[data-ending-style] {
  opacity: 0;
  transform: scale(0.97);
}
media-menu {
  transition:
    opacity 150ms ease,
    transform 150ms ease;
}

/* Hide when closed */
media-menu:not([data-open]) {
  display: none;
}

/* Submenu slides — forward: in from right, out to left */
media-menu[data-submenu][data-starting-style][data-direction="forward"] {
  transform: translateX(100%);
}
media-menu[data-submenu][data-ending-style][data-direction="forward"] {
  transform: translateX(-100%);
}

/* Submenu slides — back: in from left, out to right */
media-menu[data-submenu][data-starting-style][data-direction="back"] {
  transform: translateX(-100%);
}
media-menu[data-submenu][data-ending-style][data-direction="back"] {
  transform: translateX(100%);
}

media-menu[data-submenu] {
  transition: transform 200ms ease;
}

/* RTL — flip slide direction */
[dir="rtl"] media-menu[data-submenu][data-starting-style][data-direction="forward"] {
  transform: translateX(-100%);
}
[dir="rtl"] media-menu[data-submenu][data-ending-style][data-direction="forward"] {
  transform: translateX(100%);
}
[dir="rtl"] media-menu[data-submenu][data-starting-style][data-direction="back"] {
  transform: translateX(100%);
}
[dir="rtl"] media-menu[data-submenu][data-ending-style][data-direction="back"] {
  transform: translateX(-100%);
}

/* Item highlight — target all item types with the shared data-item marker */
[data-item][data-highlighted] {
  background: rgba(255, 255, 255, 0.1);
}

/* Checked and disabled — use ARIA attributes directly */
[aria-checked="true"]::before {
  content: '✓';
}

[data-item][aria-disabled="true"] {
  opacity: 0.4;
  pointer-events: none;
}

Transition completion detection: JS uses el.getAnimations() on the root Content element to wait for all CSS transitions and animations to settle before updating state (clearing exitingMenuId, moving focus). Same pattern as createTransition().

RTL: ArrowRight always pushes (opens submenu), ArrowLeft always pops — these are logical operations independent of text direction. The physical slide direction is flipped in CSS via [dir="rtl"].

Keyboard

Keyboard events are handled by the currently active view's container (root Content or submenu Content). Focus uses roving tabindex — only the highlighted item has tabindex="0", all others have tabindex="-1".

Key Behavior
ArrowDown Next item in current view (wraps)
ArrowUp Previous item in current view (wraps)
ArrowRight Push submenu (if focused item is a submenu Trigger)
ArrowLeft Pop submenu (if in a submenu); no-op at root
Home First item in current view
End Last item in current view
Enter / Space Activate focused item
Escape Pop submenu if in one; close menu at root
a-z, 0-9 Type-ahead search in current view

Type-ahead: Printable characters accumulate into a buffer. Search starts from the item after the current highlight and wraps. Buffer resets after 500ms of inactivity.

Accessibility

The menu follows the WAI-ARIA Menu Pattern and Menu Button Pattern.

<!-- Root level -->
<button
  aria-haspopup="menu"
  aria-expanded="true"
  aria-controls="settings-menu">
  Settings
</button>
<div id="settings-menu" role="menu" tabindex="-1">
  <!-- Submenu trigger acts as a menuitem in the parent -->
  <div data-menu-root-view>
    <div role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="0">Quality</div>
    <div role="menuitem" aria-haspopup="menu" aria-expanded="false" tabindex="-1">Speed</div>
    <div role="separator"></div>
    <div role="menuitem" tabindex="-1">Copy Link</div>
  </div>
</div>

<!-- Quality submenu (when active — replaces root view in the viewport) -->
<div role="menu" tabindex="-1" data-submenu data-open>
  <button aria-label="Back"></button>
  <div role="group" aria-labelledby="quality-label">
    <div id="quality-label">Quality</div>
    <div role="menuitemradio" aria-checked="false" tabindex="0">Auto</div>
    <div role="menuitemradio" aria-checked="true" tabindex="-1">1080p</div>
  </div>
</div>

Roles:

Part Role
Content (root or submenu) menu
Item menuitem
Trigger (when submenu trigger in parent) menuitem
RadioItem menuitemradio
CheckboxItem menuitemcheckbox
Group / RadioGroup group
Separator separator

Focus management:

Event Focus behavior
Menu opens Focus Content, then first item
Menu closes Focus returns to Trigger
Submenu push After transition: focus moves to first item in submenu Content
Submenu pop After transition: focus returns to the Trigger that initiated the push

Screen reader announcements:

  • aria-checked changes on RadioItem and CheckboxItem are announced natively.
  • Submenu title changes on push/pop use aria-live="polite" on a visually hidden region inside root Content, announcing the active view name to users who cannot see the slide animation.

Roving tabindex: Items receive real DOM focus, so :focus-visible works naturally for keyboard-only styling. Recommended by WAI-ARIA; used by Radix and Base UI.

Architecture

Three layers, each independently useful:

Layer Package Purpose
Core @videojs/core State computation, ARIA attributes, navigation stack. No DOM.
DOM @videojs/core/dom Keyboard navigation, type-ahead, submenu transitions, focus management.
UI @videojs/react, @videojs/html Compound components and custom elements.

Core layer

MenuCore follows the PopoverCore pattern — a framework-agnostic class that computes state and ARIA attributes from props and input.

interface MenuProps {
  side?: PopoverSide;
  align?: PopoverAlign;
  open?: boolean;
  defaultOpen?: boolean;
  closeOnEscape?: boolean;
  closeOnOutsideClick?: boolean;
}

interface MenuState extends TransitionFlags {
  open: boolean;
  status: TransitionStatus;
  side: PopoverSide;
  align: PopoverAlign;
  highlightedIndex: number;
  /** True when this menu instance is nested inside a parent menu. */
  isSubmenu: boolean;
}

type NavigationState = {
  stack: Array<{ menuId: string; triggerId: string }>;
  direction: 'forward' | 'back' | null;
  exitingMenuId: string | null;
  transitioning: boolean;
};

Constants follow the *-data-attrs.ts / *-css-vars.ts pattern from the slider:

// menu-data-attrs.ts
export const MenuDataAttrs = {
  open: 'data-open',
  side: 'data-side',
  align: 'data-align',
  submenu: 'data-submenu',
  startingStyle: 'data-starting-style',
  endingStyle: 'data-ending-style',
  direction: 'data-direction',
} as const;

// menu-item-data-attrs.ts
export const MenuItemDataAttrs = {
  item: 'data-item',
  highlighted: 'data-highlighted',
} as const;

// menu-css-vars.ts
export const MenuCSSVars = {
  width: '--media-menu-width',
  height: '--media-menu-height',
  availableHeight: '--media-menu-available-height',
} as const;

DOM layer

createMenu() composes createPopover() internally for open/close, positioning, and dismiss behavior (root menus only), then layers menu-specific keyboard navigation and focus management on top. When a parent MenuContext is provided, the instance operates as a submenu — no popover positioning, registers its Trigger as a parent item.

interface MenuApi {
  input: State<MenuInput>;
  navigationState: State<NavigationState>;
  triggerProps: MenuTriggerProps;
  contentProps: MenuContentProps;
  setTriggerElement: (el: HTMLElement | null) => void;
  setContentElement: (el: HTMLElement | null) => void;
  open: (reason?: PopoverOpenChangeReason) => void;
  close: (reason?: PopoverOpenChangeReason) => void;
  registerItem: (el: HTMLElement, options?: { disabled?: boolean }) => () => void;
  highlight: (index: number) => void;
  push: (menuId: string, triggerId: string) => void;
  pop: () => void;
  destroy: () => void;
}

createMenuViewTransition() handles the double-RAF lifecycle for menu view enter/exit hooks (same pattern as createTransition()). menu-viewport-transition.ts handles shared menu viewport measurement, width/height variables, and root/child view state coordination.

Item collection: Items self-register via registerItem(el) returning a cleanup function. Sorted by compareDocumentPosition. Works across Shadow DOM boundaries without coupling to ARIA role strings.

File structure

Core (packages/core/src/core/ui/menu/):

menu-core.ts
menu-data-attrs.ts
menu-item-data-attrs.ts
menu-css-vars.ts

DOM (packages/core/src/dom/ui/menu/):

create-menu.ts
create-menu-view-transition.ts
menu-viewport-transition.ts

React (packages/react/src/ui/menu/):

context.tsx
index.parts.ts
index.ts
menu-root.tsx
menu-trigger.tsx
menu-content.tsx
menu-view.tsx
menu-back.tsx
menu-item.tsx
menu-group-label.tsx
menu-separator.tsx
menu-group.tsx
menu-radio-group.tsx
menu-radio-item.tsx
menu-checkbox-item.tsx
menu-item-indicator.tsx

HTML (packages/html/src/ui/menu/):

menu-element.ts
menu-view-element.ts
menu-back-element.ts
menu-item-element.ts
menu-group-label-element.ts
menu-separator-element.ts
menu-group-element.ts
menu-radio-group-element.ts
menu-radio-item-element.ts
menu-checkbox-item-element.ts
menu-item-indicator-element.ts

Importing @videojs/html/ui/menu registers all elements.

Popover integration

createMenu() creates a createPopover() instance internally for open/close, Escape handling, outside-click dismissal, CSS Anchor Positioning (with JS fallback), and hover intent. The popover is an implementation detail, not exposed in the menu API. Menu adds what's unique: role="menu", roving tabindex, arrow key navigation, type-ahead, and the navigation stack.

For submenu instances, createPopover() is not used — the parent menu's Content already provides the constrained viewport, and open/close is driven by the navigation stack.

Prior art

YouTube / Plyr — The in-place panel navigation model this design is based on. Click a category, slide to a radio list, select, auto-slide back. Both reset to root on close.

Vidstack — Uses nested menu composition (same approach as this design) rather than separate sub-parts. Menu + nested Menu with data-submenu state. Direct inspiration for the flattened API.

Base UI Menu — Compound Menu.Root / Menu.Trigger / Menu.Positioner / Menu.Popup / Menu.Item pattern. Uses data-open, data-starting-style, data-ending-style on Popup for CSS-driven transitions. CSS custom properties (--available-height) for viewport constraints. Strongly influenced this doc's animation API.

Radix Dropdown Menu — Separate DropdownMenu.Sub / DropdownMenu.SubTrigger / DropdownMenu.SubContent for submenus. Flyout rendering (not in-place). Roving tabindex. data-state="open|closed" (we use data-open to align with Base UI).

Shadcn — Wraps Radix with default styling. Demonstrates compound namespace familiarity.

Descoped

Feature Reason
Flyout (side-opening) submenus Wrong starting point for video player settings menus. Add later via Portal-based Content if needed.
Context menus (right-click) Different trigger model and positioning concerns. Separate design.
Hover-to-open submenus Deferred. Desktop flyout behavior — not applicable to the in-place cascading model.