Context Menu
View as MarkdownOpen a menu at the pointer, or from the keyboard. Custom element
<ui-context-menu> <div data-ui-part="target" role="group" aria-label="hero-banner.avif"> hero-banner.avif </div> <ui-menu id="asset-context-menu" role="menu" popover="auto" aria-label="Asset commands"> <button role="menuitem" type="button">Open</button> <button role="menuitem" type="button">Rename</button> <hr data-ui-part="separator" role="separator"> <div data-ui-part="group"> <p data-ui-part="group-label">Share</p> <button role="menuitem" type="button">Copy link</button> <button role="menuitem" type="button">Invite reviewer</button> </div> </ui-menu></ui-context-menu>Install
import '@timelessui/components/css/tokens.css'import '@timelessui/components/css/core/context-menu.css'import '@timelessui/components/css/core/floating.css'import '@timelessui/components/css/core/menu.css'import '@timelessui/components/css/core/button.css'import '@timelessui/components/css/themes/atmosphere/tokens.css'import '@timelessui/components/css/themes/atmosphere/button.css'import '@timelessui/components/css/themes/atmosphere/menu.css'import '@timelessui/components/css/themes/atmosphere/context-menu.css'import '@timelessui/components/register/ui-context-menu'import '@timelessui/components/register/ui-menu'A register/ import is a side effect: it defines the element as the module evaluates, so it has to run in code the browser loads. To register explicitly instead — to control the timing, or to define into another window — call the function from the matching define/ entry point, which registers nothing on its own:
import { defineContextMenuElement } from '@timelessui/components/define/ui-context-menu'defineContextMenuElement()import { defineMenuElement } from '@timelessui/components/define/ui-menu'defineMenuElement()Anatomy
Parts are authored in your own markup and identified by the selector below, so your CSS can target the same anatomy without loading Timeless styles. Required parts must be present for the component to work. Private data-ui-internal-* hooks are written by the runtime and must never be authored.
<ui-context-menu>
| Part | Required | Selector | Purpose |
|---|---|---|---|
target | Yes | [data-ui-part~='target'] | The region a secondary click opens the menu over. Give it a role that supports aria-haspopup and make it focusable — Timeless adds tabindex="0" when it has none, because the keyboard path cannot exist without a tab stop. It then wires aria-haspopup, aria-controls, and aria-expanded; the role and the accessible name stay yours. |
menu | Yes | ui-menu[popover], [role='menu'][popover] | The menu surface, a ui-menu authored as a popover. Every item, group, separator, and submenu inside it is the [Menu](/docs/components/menu/) contract, unchanged — this element only decides when and where it opens. |
<ui-menu>
| Part | Required | Selector | Purpose |
|---|---|---|---|
menu | Yes | [role='menu'] | The menu container. Use role="menubar" for a persistent horizontal menu bar. |
item | Yes | [role^='menuitem'] | One command. A menu-item role is what makes an element an item — a bare <button> inside the menu is not one. Use role="menuitem", or menuitemcheckbox / menuitemradio for a checkable command. Timeless manages roving tabindex, typeahead, and aria-checked. |
group | No | [data-ui-part~='group'] | A role="group" wrapper around related items. Items inside it stay navigable, and a menuitemradio clears only the radios in its own group. |
group-label | No | [data-ui-part~='group-label'] | The label for a group, wired to it with aria-labelledby. |
separator | No | [role='separator'], hr | A divider between items or groups. Navigation and typeahead skip it, because it carries no menu-item role. |
submenu-trigger | No | [aria-haspopup='menu'] | An item that owns a submenu. You do not author this token: give the item aria-controls naming the submenu, or put the submenu immediately after it, and Timeless writes aria-haspopup, aria-controls, and aria-expanded. |
submenu | No | ui-menu[popover], [role='menu'][popover] | A nested menu opened from a submenu-trigger. Author it as a popover so it stays hidden before enhancement; Timeless adds popover="auto" if you leave it off. |
Attributes
Every value below is implemented by the stylesheets this component ships. Boolean attributes are presence-based: author the attribute with no value, or omit it.
<ui-menu>
| Attribute | Values | Default | Description |
|---|---|---|---|
orientation | horizontal · vertical | vertical | Arrow-key axis. Defaults to horizontal when the menu part is role="menubar". |
Element API
Attributes above are the authoring surface. These are the DOM properties and events the registered element adds once it upgrades.
<ui-context-menu>
This element adds no public properties. Configure it with its attributes.
| Event | Detail | Description |
|---|---|---|
ui-open | CustomEvent<ContextMenuToggleDetail> | Dispatched after the context menu opens. |
ui-close | CustomEvent<ContextMenuToggleDetail> | Dispatched after the context menu closes. |
<ui-menu>
| Property | Type | Notes |
|---|---|---|
orientation | 'horizontal' | 'vertical' | Reflects the orientation attribute, documented above. |
| Event | Detail | Description |
|---|---|---|
ui-before-change | CustomEvent<MenuCheckedDetail> | Cancelable proposal dispatched before a checkable item changes. Call preventDefault() to reject the transition and keep the current value. |
ui-change | CustomEvent<MenuCheckedDetail> | Dispatched after a checkable item has changed. Bubbles and is composed. |
Styling
Root identity: ui-context-menu, ui-menu. Required stylesheets: tokens.css, core/context-menu.css, core/floating.css, core/menu.css, core/button.css, themes/atmosphere/tokens.css, themes/atmosphere/button.css, themes/atmosphere/menu.css, themes/atmosphere/context-menu.css.
Set these custom properties to restyle the component:
| Custom property | Controls |
|---|---|
--ui-context-menu-x | Horizontal position the surface opens at, written at runtime from the pointer or from the focused element. The stylesheet turns it into a clamped left. |
--ui-context-menu-y | Vertical position, the same way. Together these are the whole positioning input: there is no anchor element, because a pointer is not an element. |
--ui-context-menu-inset | Minimum gap kept between the surface and the viewport edge when the coordinates would push it off screen. |
--ui-menu-min-inline-size | Minimum width of the menu surface. |
Design tokens this component reads (32)
These are global Atmosphere tokens. Change them once at the theme level rather than per component.
--ui-accent · --ui-accent-soft · --ui-bg-accent · --ui-bg-accent-active · --ui-bg-accent-hover · --ui-bg-control · --ui-bg-control-active · --ui-bg-control-hover · --ui-bg-control-muted · --ui-bg-danger · --ui-bg-danger-active · --ui-bg-danger-hover · --ui-bg-surface · --ui-bg-surface-raised · --ui-disabled-opacity · --ui-duration-fast · --ui-ease-standard · --ui-fg · --ui-fg-muted · --ui-focus · --ui-line · --ui-radius-control · --ui-radius-lg · --ui-radius-sm · --ui-shadow-control-accent · --ui-shadow-control-active · --ui-shadow-control-danger · --ui-shadow-floating · --ui-shadow-outline-control · --ui-space-1 · --ui-space-2 · --ui-space-3
Accessibility
Keep the native elements, roles, and relationships shown in the example. Timeless adds only the state and keyboard coordination the platform does not already provide, and it never supplies your accessible names — those depend on your content.
<ui-context-menu>
The ARIA Authoring Practices Guide has no pattern for Context Menu. The contract below is a composition of behavior the platform already defines, not ARIA invented to fill the gap.
| Key | Action |
|---|---|
| Shift + F10 | Open the menu for the focused target. Some environments consume the shortcut before the page sees it, which is why the dedicated key below is also supported. |
| Context Menu key | Open the menu for the focused target. |
| Escape | Close the menu and return focus to the target. Handled by the Popover API and by Menu. |
The APG has no context-menu pattern, so this documents a composition rather than claiming one: a [Menu](/docs/components/menu/) surface, opened by a secondary click or by the keyboard. Everything inside is the menu pattern — roving focus, typeahead, submenu keys, checkable items. Escape, light dismiss, and top-layer stacking come from the Popover API. This is the one Timeless component with no no-JavaScript fallback: the platform has no declarative way to open a surface at pointer coordinates, so with scripting off the browser shows its own context menu and the authored ui-menu stays hidden. Never put a command here that is not also reachable another way.
<ui-menu>
Follows the Menu and Menubar pattern from the ARIA Authoring Practices Guide.
| Key | Action |
|---|---|
| Arrow keys | Move focus between items along the orientation axis. |
| Home / End | Move focus to the first or last item. |
| Enter / Space | Activate the focused item, or open its submenu. |
| Arrow Right | Open the focused item's submenu and focus its first enabled item. Under dir="rtl" this is Arrow Left. |
| Arrow Left | Close the submenu and return focus to the item that opened it. From a first-level submenu of a menubar, move along the bar instead. Under dir="rtl" this is Arrow Right. |
| Escape | Close the menu and return focus to whatever opened it. |
| Printable characters | Typeahead: jump to the next item whose label starts with what you typed. |
The menu is one tab stop. Mark an unavailable command aria-disabled="true" rather than disabled: it stays reachable with the arrow keys, which is the APG treatment — a command you cannot use is easier to understand than one that is not there — while never taking the resting tab stop and never activating. An authored disabled item is honoured as written and simply skipped, because the platform makes it unfocusable and Timeless does not rewrite your markup. Activating a menuitemcheckbox toggles its aria-checked; activating a menuitemradio sets it and clears the other radios in its group. Both dispatch a cancelable ui-before-change first, so a consumer that already owns aria-checked can keep owning it. Typeahead matching is locale-aware. Submenus open on the keyboard and on click; there is deliberately no hover-with-intent opening.
Before JavaScript runs
The platform cannot open a surface at pointer coordinates without script, so the markup above is not usable on its own. With JavaScript off the browser shows its own context menu and the authored ui-menu stays hidden — which is why every command here has to be reachable somewhere else too.
More examples
Open Context Menu in StoryLite for variants, controls, and manual testing.