Skip to content

docs(design): menus#1078

Merged
sampotts merged 1 commit intomainfrom
feat/menu-ui-component
May 1, 2026
Merged

docs(design): menus#1078
sampotts merged 1 commit intomainfrom
feat/menu-ui-component

Conversation

@sampotts
Copy link
Copy Markdown
Collaborator

@sampotts sampotts commented Mar 23, 2026

Refs #1066

Closes #1381


Note

Low Risk
Docs-only change that adds a new UI design document and a spellcheck dictionary update; no runtime behavior or API surface is modified.

Overview
Adds a new draft design spec internal/design/ui/menus.md describing the proposed headless, compound Menu API for React and HTML (including nested submenus, keyboard/ARIA behavior, transition/CSS variable contract, and layered core/DOM/UI architecture).

Updates cspell.json to include vidstack in the spellchecker dictionary.

Reviewed by Cursor Bugbot for commit 53ba9ea. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI review requested due to automatic review settings March 23, 2026 05:07
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Apr 30, 2026 9:25pm

Request Review

@netlify
Copy link
Copy Markdown

netlify Bot commented Mar 23, 2026

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit 53ba9ea
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/69f3c8bf259b900008bbf526
😎 Deploy Preview https://deploy-preview-1078--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 23, 2026

📦 Bundle Size Report

🎨 @videojs/html — no changes
Presets (7)
Entry Size
/video (default) 28.83 kB
/video (default + hls) 162.13 kB
/video (minimal) 26.30 kB
/video (minimal + hls) 159.65 kB
/audio (default) 26.68 kB
/audio (minimal) 24.23 kB
/background 4.16 kB
Media (8)
Entry Size
/media/background-video 1.04 kB
/media/container 1.72 kB
/media/dash-video 236.58 kB
/media/hls-video 134.69 kB
/media/mux-audio 160.91 kB
/media/mux-video 160.85 kB
/media/native-hls-video 4.62 kB
/media/simple-hls-video 16.01 kB
Players (3)
Entry Size
/video/player 7.00 kB
/audio/player 5.12 kB
/background/player 3.86 kB
Skins (29)
Entry Type Size
/video/minimal-skin.css css 3.54 kB
/video/skin.css css 3.58 kB
/video/minimal-skin js 26.27 kB
/video/minimal-skin.tailwind js 26.48 kB
/video/skin js 28.81 kB
/video/skin.tailwind js 28.88 kB
/audio/minimal-skin.css css 2.56 kB
/audio/skin.css css 2.52 kB
/audio/minimal-skin js 24.23 kB
/audio/minimal-skin.tailwind js 24.39 kB
/audio/skin js 26.67 kB
/audio/skin.tailwind js 26.79 kB
/background/skin.css css 115 B
/background/skin js 1.15 kB
/live-video/minimal-skin.css css 3.54 kB
/live-video/skin.css css 3.58 kB
/live-video/minimal-skin js 26.06 kB
/live-video/minimal-skin.tailwind js 26.16 kB
/live-video/skin js 28.52 kB
/live-video/skin.tailwind js 28.56 kB
/live-audio/minimal-skin.css css 2.56 kB
/live-audio/skin.css css 2.52 kB
/live-audio/minimal-skin js 24.06 kB
/live-audio/minimal-skin.tailwind js 24.03 kB
/live-audio/skin js 26.42 kB
/live-audio/skin.tailwind js 26.46 kB
/base.css css 162 B
/shared.css css 88 B
/skin-element js 1.35 kB
UI Components (25)
Entry Size
/ui/alert-dialog 989 B
/ui/alert-dialog-close 465 B
/ui/alert-dialog-description 451 B
/ui/alert-dialog-title 369 B
/ui/buffering-indicator 2.49 kB
/ui/captions-button 2.59 kB
/ui/cast-button 2.62 kB
/ui/compounds 4.15 kB
/ui/controls 2.03 kB
/ui/error-dialog 3.10 kB
/ui/fullscreen-button 2.58 kB
/ui/hotkey 1.91 kB
/ui/mute-button 2.58 kB
/ui/pip-button 2.55 kB
/ui/play-button 2.56 kB
/ui/playback-rate-button 2.67 kB
/ui/popover 1.82 kB
/ui/poster 1.88 kB
/ui/seek-button 2.59 kB
/ui/slider 1.51 kB
/ui/thumbnail 2.96 kB
/ui/time 2.52 kB
/ui/time-slider 3.92 kB
/ui/tooltip 2.03 kB
/ui/volume-slider 2.66 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react — no changes
Presets (7)
Entry Size
/video (default) 23.53 kB
/video (default + hls) 155.68 kB
/video (minimal) 21.14 kB
/video (minimal + hls) 153.27 kB
/audio (default) 19.10 kB
/audio (minimal) 17.62 kB
/background 756 B
Media (7)
Entry Size
/media/background-video 575 B
/media/dash-video 235.21 kB
/media/hls-video 133.34 kB
/media/mux-audio 159.31 kB
/media/mux-video 159.49 kB
/media/native-hls-video 3.13 kB
/media/simple-hls-video 14.55 kB
Skins (26)
Entry Type Size
/video/minimal-skin.css css 3.49 kB
/video/skin.css css 3.50 kB
/video/minimal-skin js 21.05 kB
/video/minimal-skin.tailwind js 24.59 kB
/video/skin js 23.46 kB
/video/skin.tailwind js 24.73 kB
/audio/minimal-skin.css css 2.45 kB
/audio/skin.css css 2.40 kB
/audio/minimal-skin js 17.57 kB
/audio/minimal-skin.tailwind js 20.07 kB
/audio/skin js 19.03 kB
/audio/skin.tailwind js 20.06 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 3.49 kB
/live-video/skin.css css 3.50 kB
/live-video/minimal-skin js 17.79 kB
/live-video/minimal-skin.tailwind js 21.18 kB
/live-video/skin js 20.18 kB
/live-video/skin.tailwind js 21.39 kB
/live-audio/minimal-skin.css css 2.45 kB
/live-audio/skin.css css 2.40 kB
/live-audio/minimal-skin js 15.79 kB
/live-audio/minimal-skin.tailwind js 18.06 kB
/live-audio/skin js 17.26 kB
/live-audio/skin.tailwind js 18.19 kB
UI Components (20)
Entry Size
/ui/alert-dialog 1.11 kB
/ui/buffering-indicator 1.85 kB
/ui/captions-button 2.03 kB
/ui/cast-button 2.04 kB
/ui/controls 1.89 kB
/ui/error-dialog 2.31 kB
/ui/fullscreen-button 2.03 kB
/ui/mute-button 2.05 kB
/ui/pip-button 2.03 kB
/ui/play-button 2.00 kB
/ui/playback-rate-button 2.02 kB
/ui/popover 1.87 kB
/ui/poster 1.74 kB
/ui/seek-button 2.10 kB
/ui/slider 3.33 kB
/ui/thumbnail 2.08 kB
/ui/time 2.54 kB
/ui/time-slider 3.00 kB
/ui/tooltip 2.17 kB
/ui/volume-slider 2.39 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core — no changes
Entries (9)
Entry Size
. 4.96 kB
/dom 11.93 kB
/dom/media/custom-media-element 1.90 kB
/dom/media/dash 234.36 kB
/dom/media/google-cast 4.07 kB
/dom/media/hls 132.72 kB
/dom/media/mux 158.81 kB
/dom/media/native-hls 2.52 kB
/dom/media/simple-hls 13.89 kB
🏷️ @videojs/element — no changes
Entries (2)
Entry Size
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Size
. 1.39 kB
/html 695 B
/react 360 B
🔧 @videojs/utils — no changes
Entries (10)
Entry Size
/array 104 B
/dom 1.92 kB
/events 319 B
/function 327 B
/object 275 B
/predicate 265 B
/string 148 B
/style 190 B
/time 478 B
/number 158 B
📦 @videojs/spf — no changes
Entries (3)
Entry Size
. 4.29 kB
/dom 13.40 kB
/playback-engine 13.26 kB

ℹ️ How to interpret

All sizes are standalone totals (minified + brotli).

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current sizes.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@videojs videojs deleted a comment from Copilot AI Mar 23, 2026
@videojs videojs deleted a comment from Copilot AI Mar 23, 2026
@videojs videojs deleted a comment from Copilot AI Mar 23, 2026
@videojs videojs deleted a comment from Copilot AI Mar 23, 2026
@videojs videojs deleted a comment from Copilot AI Mar 23, 2026
@videojs videojs deleted a comment from Copilot AI Mar 23, 2026
@videojs videojs deleted a comment from Copilot AI Mar 23, 2026
Copy link
Copy Markdown
Member

@mihar-22 mihar-22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blockers

  1. "Flyout" and "panel" aren't established terminology. The component library ecosystem (Base UI, Radix) uses "submenu" - the distinction is how submenus render (cascading vs non-cascading), not a different component category. Introducing unfamiliar vocabulary adds friction for anyone coming from or working simultaneously with these libraries.
  2. mode prop creates a polymorphic composition tree. The valid set of children depends on a runtime prop. Panel parts are no-ops in flyout mode, SubMenu parts are no-ops in panel mode. TypeScript can't catch this, and there's no runtime error either. It'd be cleaner if the composition itself determined the behavior rather than a prop changing which children are valid.
  3. Cascading (flyout) submenus may not be the right starting point for the player. The player operates in a constrained popover - cascading submenus that open as separate positioned popups don't fit well here. In-place transitions within a single container are what YouTube, Plyr, and most video player settings UIs use. It might make more sense to ground the design in non-cascading submenus rather than starting cascading first with a mode escape hatch for panels.
  4. Panel navigation model feels premature. PanelNavigationState, createCarouselTransition(), panel stack, rapid-navigation cancellation, 4 panel-specific parts - there's a lot of machinery here
  5. Panel parts overlap with SubMenu semantics. Panel is effectively a SubMenuContent rendered in-place, PanelTrigger is a SubMenuTrigger, PanelBack is a SubMenuTrigger in its open state. The hierarchy is the same - the difference is rendering strategy (in-place vs cascading). Base UI's NavigationMenu handles this distinction compositionally (Portal presence vs inline Viewport) rather than with parallel parts. Worth considering the same approach here.

Suggested direction

Non-cascading submenus as the default, using the existing SubMenu parts. Content acts as the viewport/transition container. SubMenuTrigger handles both forward and back navigation via visual state (data-open). Cascading (flyout) support can be additive later via Portal composition.

Transitions

Transitions and sizing are CSS-driven via custom properties and data attributes on Content:

  • --menu-width / --menu-height - measured dimensions of the active submenu, enabling smooth container resize transitions
  • --available-height - from popover positioning, for scroll containment on long lists
  • data-open - transition direction for slide animations

React

<Menu.Root>
  <Menu.Trigger>Settings</Menu.Trigger>
  <Menu.Content>
    <Menu.SubMenu>
      <Menu.SubMenuTrigger>Quality</Menu.SubMenuTrigger>
      <Menu.SubMenuContent>
        <Menu.RadioGroup value={quality} onValueChange={setQuality} label="Quality">
          <Menu.RadioItem value="auto">Auto</Menu.RadioItem>
          <Menu.RadioItem value="1080p">1080p</Menu.RadioItem>
          <Menu.RadioItem value="720p">720p</Menu.RadioItem>
        </Menu.RadioGroup>
      </Menu.SubMenuContent>
    </Menu.SubMenu>
    <Menu.SubMenu>
      <Menu.SubMenuTrigger>Speed</Menu.SubMenuTrigger>
      <Menu.SubMenuContent>
        <Menu.RadioGroup value={speed} onValueChange={setSpeed} label="Speed">
          <Menu.RadioItem value="0.5">0.5x</Menu.RadioItem>
          <Menu.RadioItem value="1">Normal</Menu.RadioItem>
          <Menu.RadioItem value="2">2x</Menu.RadioItem>
        </Menu.RadioGroup>
      </Menu.SubMenuContent>
    </Menu.SubMenu>
  </Menu.Content>
</Menu.Root>

HTML

<button commandfor="settings-menu">Settings</button>
<media-menu id="settings-menu">
  <media-menu-item commandfor="quality-menu">Quality</media-menu-item>
  <media-menu id="quality-menu">
    <media-menu-radio-group label="Quality">
      <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-item commandfor="speed-menu">Speed</media-menu-item>
  <media-menu id="speed-menu">
    <media-menu-radio-group label="Speed">
      <media-menu-radio-item value="0.5">0.5x</media-menu-radio-item>
      <media-menu-radio-item value="1">Normal</media-menu-radio-item>
      <media-menu-radio-item value="2">2x</media-menu-radio-item>
    </media-menu-radio-group>
  </media-menu>
</media-menu>

Doc structure

We simplified design doc conventions in #1173. Recommendations:

  • Rename index.md to menus.md as the single design doc - anatomy, API surface, keyboard, accessibility
  • Decisions around cascading vs non-cascading handling can live in menus.md directly
  • Drop decisions.md - add decisions as they're debated during review and implementation
  • Drop architecture.md - this is an implementation plan, save for that phase
  • Drop parts.md - redundant with the API surface already in menus.md, add per-part detail inline if needed

Out of scope

  • Cascading (flyout) submenus - the player doesn't need them right now. Can be added later via Portal composition if a use case appears.
  • Context menus - needs its own design, starting with what we'd actually use them for in the player.

@sampotts
Copy link
Copy Markdown
Collaborator Author

sampotts commented Apr 1, 2026

Blockers

  1. "Flyout" and "panel" aren't established terminology. The component library ecosystem (Base UI, Radix) uses "submenu" - the distinction is how submenus render (cascading vs non-cascading), not a different component category. Introducing unfamiliar vocabulary adds friction for anyone coming from or working simultaneously with these libraries.

Happy to change naming but flyout menu is a common design term outside of Base UI circles, such as the W3C - their example refers to navigation but the principle is the same. Maybe "panel" (or pane) isn't so obvious but to me sub-menu is too ambiguous as that could apply to either design pattern, hence needing some form of differentiation - a problem that Base UI doesn't have.

  1. mode prop creates a polymorphic composition tree. The valid set of children depends on a runtime prop. Panel parts are no-ops in flyout mode, SubMenu parts are no-ops in panel mode. TypeScript can't catch this, and there's no runtime error either. It'd be cleaner if the composition itself determined the behavior rather than a prop changing which children are valid.

Sure, we could have completely different components paths for each type.

  1. Cascading (flyout) submenus may not be the right starting point for the player. The player operates in a constrained popover - cascading submenus that open as separate positioned popups don't fit well here. In-place transitions within a single container are what YouTube, Plyr, and most video player settings UIs use. It might make more sense to ground the design in non-cascading submenus rather than starting cascading first with a mode escape hatch for panels.

Yep, fully aware of the constraints of course and that's why I built Plyr the way I did but we may also want to consider offering some variance in design, otherwise skins all start looking very similar. Happy to go down that path though and add some more options later.

  1. Panel navigation model feels premature. PanelNavigationState, createCarouselTransition(), panel stack, rapid-navigation cancellation, 4 panel-specific parts - there's a lot of machinery here

Yep, I think some of it will come out in the wash as I build it. Not aiming to get a 100% waterfall implementation documented here. Just the general direction.

  1. Panel parts overlap with SubMenu semantics. Panel is effectively a SubMenuContent rendered in-place, PanelTrigger is a SubMenuTrigger, PanelBack is a SubMenuTrigger in its open state. The hierarchy is the same - the difference is rendering strategy (in-place vs cascading). Base UI's NavigationMenu handles this distinction compositionally (Portal presence vs inline Viewport) rather than with parallel parts. Worth considering the same approach here.

Will take a look.

Suggested direction

Non-cascading submenus as the default, using the existing SubMenu parts. Content acts as the viewport/transition container. SubMenuTrigger handles both forward and back navigation via visual state (data-open). Cascading (flyout) support can be additive later via Portal composition.

Transitions

Transitions and sizing are CSS-driven via custom properties and data attributes on Content:

  • --menu-width / --menu-height - measured dimensions of the active submenu, enabling smooth container resize transitions
  • --available-height - from popover positioning, for scroll containment on long lists
  • data-open - transition direction for slide animations

React

<Menu.Root>
  <Menu.Trigger>Settings</Menu.Trigger>
  <Menu.Content>
    <Menu.SubMenu>
      <Menu.SubMenuTrigger>Quality</Menu.SubMenuTrigger>
      <Menu.SubMenuContent>
        <Menu.RadioGroup value={quality} onValueChange={setQuality} label="Quality">
          <Menu.RadioItem value="auto">Auto</Menu.RadioItem>
          <Menu.RadioItem value="1080p">1080p</Menu.RadioItem>
          <Menu.RadioItem value="720p">720p</Menu.RadioItem>
        </Menu.RadioGroup>
      </Menu.SubMenuContent>
    </Menu.SubMenu>
    <Menu.SubMenu>
      <Menu.SubMenuTrigger>Speed</Menu.SubMenuTrigger>
      <Menu.SubMenuContent>
        <Menu.RadioGroup value={speed} onValueChange={setSpeed} label="Speed">
          <Menu.RadioItem value="0.5">0.5x</Menu.RadioItem>
          <Menu.RadioItem value="1">Normal</Menu.RadioItem>
          <Menu.RadioItem value="2">2x</Menu.RadioItem>
        </Menu.RadioGroup>
      </Menu.SubMenuContent>
    </Menu.SubMenu>
  </Menu.Content>
</Menu.Root>

HTML

<button commandfor="settings-menu">Settings</button>
<media-menu id="settings-menu">
  <media-menu-item commandfor="quality-menu">Quality</media-menu-item>
  <media-menu id="quality-menu">
    <media-menu-radio-group label="Quality">
      <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-item commandfor="speed-menu">Speed</media-menu-item>
  <media-menu id="speed-menu">
    <media-menu-radio-group label="Speed">
      <media-menu-radio-item value="0.5">0.5x</media-menu-radio-item>
      <media-menu-radio-item value="1">Normal</media-menu-radio-item>
      <media-menu-radio-item value="2">2x</media-menu-radio-item>
    </media-menu-radio-group>
  </media-menu>
</media-menu>

Doc structure

We simplified design doc conventions in #1173. Recommendations:

  • Rename index.md to menus.md as the single design doc - anatomy, API surface, keyboard, accessibility
  • Decisions around cascading vs non-cascading handling can live in menus.md directly
  • Drop decisions.md - add decisions as they're debated during review and implementation
  • Drop architecture.md - this is an implementation plan, save for that phase
  • Drop parts.md - redundant with the API surface already in menus.md, add per-part detail inline if needed

Sure, this all changed after your recent PR so I'll update. Its current state was based on the previous standards we had in place.

Out of scope

  • Cascading (flyout) submenus - the player doesn't need them right now. Can be added later via Portal composition if a use case appears.
  • Context menus - needs its own design, starting with what we'd actually use them for in the player.

Context menus (as in right click) aren't covered in this design doc.

mihar-22
mihar-22 previously approved these changes Apr 21, 2026
Copy link
Copy Markdown
Member

@mihar-22 mihar-22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome 😎 Left some feedback/suggestions around how menu parts can be simplified but this looks great!

Comment thread internal/design/ui/menus.md Outdated
Comment thread internal/design/ui/menus.md Outdated
Comment thread internal/design/ui/menus.md Outdated
Comment thread internal/design/ui/menus.md Outdated
@sampotts sampotts merged commit cc1b564 into main May 1, 2026
26 checks passed
@sampotts sampotts deleted the feat/menu-ui-component branch May 1, 2026 03:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Design: Internationalization

3 participants