diff --git a/cspell.json b/cspell.json
index 16b5b73f..29862795 100644
--- a/cspell.json
+++ b/cspell.json
@@ -17,6 +17,7 @@
"shiki",
"squircle",
"videojs",
+ "vidstack",
"vitesse",
"wouter"
],
diff --git a/internal/design/ui/menus.md b/internal/design/ui/menus.md
new file mode 100644
index 00000000..8cee8c4e
--- /dev/null
+++ b/internal/design/ui/menus.md
@@ -0,0 +1,805 @@
+---
+status: draft
+date: 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.
+
+```tsx
+import { Menu } from '@videojs/react';
+
+
+ Settings
+
+
+
+ Quality
+
+
+
+ Auto
+ 1080p
+ 720p
+
+
+
+
+
+ Speed
+
+
+
+ 0.5×
+ Normal
+ 2×
+
+
+
+
+
+ Copy Link
+
+
+
+```
+
+### HTML
+
+Submenus are nested `` elements. A `` with `commandfor` links to its target submenu by ID — consistent with how other floating components use the invoker API.
+
+```ts
+import '@videojs/html/ui/menu';
+```
+
+```html
+
+
+
+ Quality
+
+
+
+ Auto
+ 1080p
+ 720p
+
+
+
+ Speed
+
+
+
+ 0.5×
+ Normal
+ 2×
+
+
+
+
+ Copy Link
+
+
+```
+
+### Parts
+
+All parts are exported under `Menu.*` (React) or as `` elements (HTML). One import gives access to everything:
+
+```ts
+import { Menu } from '@videojs/react';
+// Menu.Root, Menu.Trigger, Menu.Content, Menu.Back,
+// Menu.Item, Menu.Label, 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, `` 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` | Custom render element. |
+
+---
+
+#### Content
+
+Popup container. At the root level, handles popover positioning and dismiss behavior. Acts as the **viewport** for navigation — only one view is visible at a time.
+
+When nested (submenu), does not use popover positioning. Instead it renders in-place as a slide-transition view inside the parent Content's viewport. Gets `data-submenu` to distinguish it from the root Content.
+
+**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 |
+
+**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** (`` dispatches, all bubble):
+
+| Event | Detail | Fires when |
+|-------|--------|------------|
+| `open-change` | `{ open: boolean }` | Menu opens or closes |
+
+---
+
+#### 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` | — | 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` | — | 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.
+
+---
+
+#### Label
+
+Non-interactive heading within a group. Not keyboard-navigable.
+
+**Props:** `render`.
+
+**ARIA (automatic):** `role="presentation"`.
+
+---
+
+#### Separator
+
+Visual divider between groups or items. Not focusable.
+
+**ARIA (automatic):** `role="separator"`.
+
+---
+
+#### Group
+
+Groups related items for assistive technology.
+
+**Props:**
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `label` | `string` | Accessible label (`aria-label`). |
+| `render` | `RenderProp` | Custom render element. |
+
+**ARIA (automatic):** `role="group"`, `aria-label`.
+
+---
+
+#### 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. |
+| `label` | `string` | — | Accessible group label. |
+| `render` | `RenderProp` | — | Custom render element. |
+
+**ARIA (automatic):** `role="group"`, `aria-label`.
+
+---
+
+#### 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` | — | 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` | — | 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 | `` |
+| Back | `` |
+| Item | `` |
+| Label | `` |
+| Separator | `` |
+| Group | `` |
+| RadioGroup | `` |
+| RadioItem | `` |
+| CheckboxItem | `` |
+| ItemIndicator | `` |
+
+Submenus are expressed by nesting `` and linking via `commandfor`. No separate sub-element tags are needed.
+
+## Navigation model
+
+Content acts as a fixed-size **viewport**. Only one view is visible at a time: the root list or one submenu's content. Navigation is modelled as a **stack**:
+
+```ts
+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 entirely by data attributes and CSS custom properties. No inline styles are applied.
+
+### 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-open` | present/absent | This submenu is the active view |
+| `data-starting-style` | present/absent | Submenu is entering |
+| `data-ending-style` | present/absent | Submenu 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
+
+```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 */
+@starting-style {
+ media-menu[data-open] {
+ opacity: 0;
+ transform: scale(0.97);
+ }
+}
+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](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) and [Menu Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/).
+
+```html
+
+
+
+
+
Quality
+
Speed
+
+
Copy Link
+
+
+
+
+
+
+
Auto
+
1080p
+
+
+```
+
+**Roles:**
+
+| Part | Role |
+|------|------|
+| Content (root or submenu) | `menu` |
+| Item | `menuitem` |
+| Trigger (when submenu trigger in parent) | `menuitem` |
+| RadioItem | `menuitemradio` |
+| CheckboxItem | `menuitemcheckbox` |
+| Group / RadioGroup | `group` |
+| Label | `presentation` |
+| 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.
+
+```ts
+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:
+
+```ts
+// 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.
+
+```ts
+interface MenuApi {
+ input: State;
+ navigationState: State;
+ 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;
+}
+```
+
+`createSubMenuTransition()` handles the double-RAF lifecycle for submenu navigation (same pattern as `createTransition()`).
+
+**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/`):
+
+```text
+menu-core.ts
+menu-data-attrs.ts
+menu-item-data-attrs.ts
+menu-css-vars.ts
+```
+
+**DOM** (`packages/core/src/dom/ui/menu/`):
+
+```text
+create-menu.ts
+create-sub-menu-transition.ts
+```
+
+**React** (`packages/react/src/ui/menu/`):
+
+```text
+context.tsx
+index.parts.ts
+index.ts
+menu-root.tsx
+menu-trigger.tsx
+menu-content.tsx
+menu-back.tsx
+menu-item.tsx
+menu-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/`):
+
+```text
+menu-element.ts
+menu-back-element.ts
+menu-item-element.ts
+menu-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. |