--- 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. 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`. ```tsx import { Menu } from '@videojs/react'; Settings Quality Quality Auto 1080p 720p Speed Speed 0.5× Normal 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. For the in-place settings-menu pattern, the root list is wrapped in ``. Like `Menu.View`, it is a shared-viewport view-navigation boundary, not a requirement for future flyout submenu rendering. ```ts import '@videojs/html/ui/menu'; ``` ```html Quality Speed Copy Link Quality Auto 1080p 720p Speed 0.5× Normal ``` ### 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.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, `` 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. When paired with `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** (`` 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` / `` 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` | — | 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. --- #### 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` | 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` | — | 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` | — | 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 | `` | | View | `` | | Back | `` | | Item | `` | | GroupLabel | `` | | Separator | `` | | Group | `` | | RadioGroup | `` | | RadioItem | `` | | CheckboxItem | `` | | ItemIndicator | `` | In-place submenus are expressed by nesting sibling `` elements in the parent `` and linking triggers via `commandfor`. The root list lives in `` 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**: ```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 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 ```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](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
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` | | 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; } ``` `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/`): ```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-menu-view-transition.ts menu-viewport-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-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/`): ```text 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. |