Skip to content

Roving keyboard navigation for commands. Custom element

Menu
Copy this markup
<ui-menu role="menu" aria-label="Editor commands">
<button role="menuitem" type="button">Duplicate</button>
<button role="menuitem" type="button">Archive</button>
<button role="menuitem" type="button" aria-disabled="true">Delete</button>
<hr data-ui-part="separator" role="separator">
<div data-ui-part="group">
<p data-ui-part="group-label">View</p>
<button role="menuitemcheckbox" type="button" aria-checked="true">Show grid</button>
<button role="menuitemcheckbox" type="button" aria-checked="false">Show rulers</button>
</div>
<hr data-ui-part="separator" role="separator">
<div data-ui-part="group">
<p data-ui-part="group-label">Density</p>
<button role="menuitemradio" type="button" aria-checked="true">Comfortable</button>
<button role="menuitemradio" type="button" aria-checked="false">Compact</button>
</div>
</ui-menu>

Install

Styles and registration
import '@timelessui/components/css/tokens.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/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:

Explicit registration
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.

PartRequiredSelectorPurpose
menuYes[role='menu']The menu container. Use role="menubar" for a persistent horizontal menu bar.
itemYes[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.
groupNo[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-labelNo[data-ui-part~='group-label']The label for a group, wired to it with aria-labelledby.
separatorNo[role='separator'], hrA divider between items or groups. Navigation and typeahead skip it, because it carries no menu-item role.
submenu-triggerNo[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.
submenuNoui-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.

AttributeValuesDefaultDescription
orientationhorizontal · verticalverticalArrow-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-menu>

PropertyTypeNotes
orientation'horizontal' | 'vertical'Reflects the orientation attribute, documented above.
EventDetailDescription
ui-before-changeCustomEvent<MenuCheckedDetail>Cancelable proposal dispatched before a checkable item changes. Call preventDefault() to reject the transition and keep the current value.
ui-changeCustomEvent<MenuCheckedDetail>Dispatched after a checkable item has changed. Bubbles and is composed.

Styling

Root identity: ui-menu. Required stylesheets: tokens.css, core/floating.css, core/menu.css, core/button.css, themes/atmosphere/tokens.css, themes/atmosphere/button.css, themes/atmosphere/menu.css.

Set these custom properties to restyle the component:

Custom propertyControls
--ui-menu-min-inline-sizeMinimum 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.

Follows the Menu and Menubar pattern from the ARIA Authoring Practices Guide.

KeyAction
Arrow keysMove focus between items along the orientation axis.
Home / EndMove focus to the first or last item.
Enter / SpaceActivate the focused item, or open its submenu.
Arrow RightOpen the focused item's submenu and focus its first enabled item. Under dir="rtl" this is Arrow Left.
Arrow LeftClose 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.
EscapeClose the menu and return focus to whatever opened it.
Printable charactersTypeahead: 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 markup above is complete and usable on its own: native controls submit, labels associate, and authored ARIA is already correct. Registration adds state synchronisation, focus management, and keyboard coordination on top of it.

Navigation Menu

A bar of links with panels, composed from Hover Card. No navigation-menu element, and no menu role.

Navigation Menu
Copy this markup
<nav aria-label="Main">
<ul class="ui-group" data-ui-density="compact" data-ui-wrap>
<li>
<ui-hover-card open-delay="120" close-delay="100">
<button class="ui-button" data-ui-variant="ghost" data-ui-part="trigger" type="button" aria-controls="nav-products" aria-expanded="false">Products</button>
<div id="nav-products" popover="manual" role="group" aria-labelledby="nav-products-title">
<h2 id="nav-products-title">Products</h2>
<ul class="ui-list" data-ui-density="compact">
<li data-ui-part="item"><a class="ui-link" href="/docs/components/">Components</a></li>
<li data-ui-part="item"><a class="ui-link" href="/docs/styling/theming/">Styling</a></li>
</ul>
</div>
</ui-hover-card>
</li>
<li>
<ui-hover-card open-delay="120" close-delay="100">
<button class="ui-button" data-ui-variant="ghost" data-ui-part="trigger" type="button" aria-controls="nav-resources" aria-expanded="false">Resources</button>
<div id="nav-resources" popover="manual" role="group" aria-labelledby="nav-resources-title">
<h2 id="nav-resources-title">Resources</h2>
<ul class="ui-list" data-ui-density="compact">
<li data-ui-part="item"><a class="ui-link" href="/docs/getting-started/installation/">Getting started</a></li>
<li data-ui-part="item"><a class="ui-link" href="/docs/getting-started/agents/">Coding agents</a></li>
</ul>
</div>
</ui-hover-card>
</li>
<li><a class="ui-button" data-ui-variant="ghost" href="/docs/reference/scope/">Scope</a></li>
</ul>
</nav>

More examples

Open Menu in StoryLite for variants, controls, and manual testing.