Context menu
Context menu — the menu you already have, reached by a different gesture.
How this differs from what it looks like
Not menu
They are not alternatives, and context-menu is not a kind of menu: the panel is a menu, with the same classes, the same role="menu", the same items and the same keyboard contract. What this component adds is one thing — how it is summoned. A menu is opened by a button you can see, so its trigger is discoverable, nameable and reachable by Tab. A context menu is opened by right-click, long-press or the ContextMenu key on something that is not a button at all — a row, a cell, a canvas — and has no visible trigger to find. The rule for choosing follows from that: if there is a control on screen whose job is to open it, that is menu and nothing here applies. If the target is content and the gesture is the only way in, add data-context-menu to the target and context-menu to the panel, alongside menu, which is still what makes the arrow keys work. Every command in a context menu must also be reachable somewhere visible, because a shortcut that is the only route is not a shortcut.
Examples
<ul class="list list-divided" role="list">
<li>
<button class="list-action" type="button" data-context-menu="demo-ctx">
<span class="list-leading">
<svg class="icon icon-md" aria-hidden="true"><use href="#icon-file"></use></svg>
</span>
<span class="list-content">
<span class="list-title">quarterly-report.pdf</span>
<span class="list-support">Right-click, long-press, or press the ContextMenu key</span>
</span>
</button>
</li>
<li>
<button class="list-action" type="button" data-context-menu="demo-ctx">
<span class="list-leading">
<svg class="icon icon-md" aria-hidden="true"><use href="#icon-file"></use></svg>
</span>
<span class="list-content">
<span class="list-title">budget-2026.xlsx</span>
<span class="list-support">Right-click, long-press, or press the ContextMenu key</span>
</span>
</button>
</li>
<li>
<button class="list-action" type="button" data-context-menu="demo-ctx">
<span class="list-leading">
<svg class="icon icon-md" aria-hidden="true"><use href="#icon-file"></use></svg>
</span>
<span class="list-content">
<span class="list-title">notes.md</span>
<span class="list-support">Right-click, long-press, or press the ContextMenu key</span>
</span>
</button>
</li>
</ul>
<div class="menu popover popover-plain context-menu" id="demo-ctx" popover
role="menu" aria-label="File actions" data-ui="menu context-menu">
<button class="menu-item" type="button" role="menuitem">Rename</button>
<button class="menu-item" type="button" role="menuitem">Duplicate</button>
<hr class="menu-separator" />
<button class="menu-item menu-item-danger" type="button" role="menuitem">Delete</button>
</div>The rows are the list component and each one is a real <button>, which is what makes the gesture usable: it is focusable without a hand-written tabindex, it is announced as something you can act on, and the ContextMenu key has somewhere to land.
Note that the rows carry .list-action and sit inside a .list: a class like .menu-item only works inside the component that declares its tokens, and used on its own it renders as unstyled text. data-ui="menu context-menu" is two components on one element: menu brings the keyboard contract the role promises, context-menu brings the gesture. Focus a row and press the ContextMenu key or Shift+F10 — the keyboard path is not optional, because right-click is unavailable to many touch users and absent from most switch-access and voice-control setups. Every command here must also be reachable somewhere visible; a context menu is a shortcut, never the only route. Opening it means calling preventDefault() on contextmenu, which costs the user Back, Reload and Inspect — so it is scoped to these rows and never to the page.
<ul class="list list-divided" role="list">
<li>
<button class="list-action" type="button" data-context-menu="demo-ctx-sub">
<span class="list-leading">
<svg class="icon icon-md" aria-hidden="true"><use href="#icon-file"></use></svg>
</span>
<span class="list-content">
<span class="list-title">2026-budget.xlsx</span>
<span class="list-support">Right-click, then Arrow Right on “Move to”</span>
</span>
</button>
</li>
</ul>
<div class="menu context-menu" id="demo-ctx-sub" popover
role="menu" aria-label="File actions" data-ui="menu context-menu">
<button class="menu-item" type="button" role="menuitem">Open</button>
<button class="menu-item menu-item-parent" type="button" role="menuitem"
command="toggle-popover" commandfor="demo-ctx-move">Move to</button>
<button class="menu-item menu-item-parent" type="button" role="menuitem"
command="toggle-popover" commandfor="demo-ctx-share">Share with</button>
<hr class="menu-separator" />
<button class="menu-item menu-item-danger" type="button" role="menuitem">Delete</button>
</div>
<div class="menu popover-inline-end" id="demo-ctx-move" popover
role="menu" aria-label="Move to" data-ui="menu">
<button class="menu-item" type="button" role="menuitem">Archive</button>
<button class="menu-item" type="button" role="menuitem">Shared drive</button>
<button class="menu-item menu-item-parent" type="button" role="menuitem"
command="toggle-popover" commandfor="demo-ctx-2026">2026</button>
</div>
<div class="menu popover-inline-end" id="demo-ctx-2026" popover
role="menu" aria-label="2026" data-ui="menu">
<button class="menu-item" type="button" role="menuitem">Q1</button>
<button class="menu-item" type="button" role="menuitem">Q2</button>
</div>
<div class="menu popover-inline-end" id="demo-ctx-share" popover
role="menu" aria-label="Share with" data-ui="menu">
<button class="menu-item" type="button" role="menuitem">Anyone with the link</button>
<button class="menu-item" type="button" role="menuitem">People in your team</button>
</div>A context menu nests exactly the way a menu does, because it is a menu — context-menu only supplies the gesture that opens it, and every level below the first is an ordinary menu opened by a .menu-item-parent inside its parent.
That relationship is the platform's: the submenu's trigger lives inside the parent popover, so the parent stays open instead of light-dismissing itself, and nothing here maintains a stack. Three levels deep works for the same reason two does. From the keyboard: Arrow Right opens the level under the highlighted row, Arrow Left closes it and puts you back on the row that opened it, and the arrows keep moving through the level you are actually in — that last part was a defect worth naming, because a submenu trigger is also an invoker and its own Arrow Down handler used to fire first and swallow the keystroke.
Tokens 4
Level 2, declared on .context-menu itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --context-menu-duration | var(--duration-instant) |
| --context-menu-min-inline-size | 12rem |
| --popover-area | block-end span-inline-end |
| --popover-try | flip-block, flip-inline, flip-block flip-inline |
Variants and states
Variants
None. It is one shape, and the page does the rest.
Inside it
.context-target
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:focus:popover-opendata-context-menudata-context-opendata-legacy
Before you ship it
This one needs a script. Opt the markup in with data-ui="context-menu" and import import 'mostlycss/js/components/context-menu' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.
What you have to do 6 requirements
- Every command in the panel must also be reachable somewhere visible — a toolbar, an actions button on the row, a page-level menu. Right-click is unavailable to many touch users, awkward on trackpads, absent from most switch-access and voice-control setups, and undiscoverable to everyone.
- Give each target
tabindex="0"if it is not already focusable. The ContextMenu key and Shift+F10 open the menu at the focused element, and an element that cannot hold focus has no way to be chosen. - Ship
data-ui="menu context-menu"and the module. There is no roleless variant of this component: the gesture is the script. - Keep
.popoverin the class list. Without it the panel opens unstyled in the corner of the screen, which reads as a positioning bug. - Name the menu with
aria-label. - Scope
data-context-menuto the rows it belongs to — never<body>, and never over prose or an editable field, where the browser's own menu is the more useful one.
src/css/components/context-menu.css · npx mostlycss add context-menu