oriUI

Menu

A WAI-ARIA menu button: a trigger opens a roving-tabindex action list. Unlike the Popover, open/close, focus movement, and click-outside dismissal are driven entirely by the @oriui/headless menu machine in JavaScript. OriMenu still opens the panel in the top layer (popover="manual" + showPopover()), so an ancestor's transform, overflow or z-index cannot displace or clip it, and places it with the same CSS Anchor Positioning primitive as the Popover (ori-anchored), zero positioning JS. In your own markup, do the same; without popover the panel falls back to its z-index.

The examples are organized by layer: the class reference is the standalone @oriui/css layer, and the Framework API is the @oriui/vue component. Every example is live — flip its code between HTML (the standalone classes, also your htmx / Astro / Svelte / plain-HTML usage), Vue; HTML is the default.

Classes

A menu is the .ori-menu surface class plus the shared .ori-anchored placement primitive (and one placement modifier); items are .ori-menu__item, grouping rules .ori-menu__separator. The .ori-anchored* classes are the same primitive documented on the Popover page — placement, the collision flip, and the --ori-anchor / --ori-anchored-gap custom props are shared, not duplicated here.

ClassTypeDescription

À la carte: the classes above ship in @oriui/css/components/menu.css. The shared .ori-anchored placement primitive is inlined here, so anchored.css needs no separate import. Import a foundation (@oriui/css/base.css or @oriui/css/tokens.css) first — the token utilities (ori-color_*, ori-size-radius_*, …) live there, not in the component file. The full bundle @oriui/css is the default and already carries both; see à-la-carte imports.

Basic

Open the menu and use the Arrow keys — focus moves between items with roving tabindex (one item is tabindex=0 at a time), not aria-activedescendant. Click an item, or press Enter/Space on the highlighted one, to select and close.

html
<!-- Structure only — the JS engine (roving tabindex, open/close, click-outside) is not shown here. -->
<button
    id="menu-1-trigger"
    type="button"
    style="anchor-name: --menu-1"
    aria-haspopup="menu"
    aria-controls="menu-1"
    aria-expanded="false"
>
    Actions
</button>
<div
    id="menu-1"
    role="menu"
    tabindex="-1"
    class="ori-menu ori-anchored ori-anchored_bottom-start"
    style="--ori-anchor: --menu-1"
    aria-labelledby="menu-1-trigger"
    hidden
>
    <div role="menuitem" tabindex="-1" class="ori-menu__item">New file</div>
    <div role="menuitem" tabindex="-1" class="ori-menu__item">Open…</div>
    <div role="menuitem" tabindex="-1" class="ori-menu__item">Delete</div>
</div>
vue
<OriMenu
    :items="[
        { value: 'new', label: 'New file' },
        { value: 'open', label: 'Open…' },
        { value: 'delete', label: 'Delete' }
    ]"
    @select="onSelect"
>
    <template #trigger="{ props }">
        <OriButton v-bind="props" label="Actions" variant="soft" />
    </template>
</OriMenu>

Placements

Twelve placements — bottom-start is the default. A bare side (top / bottom / left / right) centers on the cross axis; -start / -end align to the trigger's start / end edge (logical, RTL-aware). Same placement classes and collision-flip logic as the Popover, since both compose .ori-anchored.

html
<!-- swap the modifier: ori-anchored_top / _top-start / _top-end / _bottom / _bottom-start / _bottom-end / _left / _left-start / _left-end / _right / _right-start / _right-end -->
<div
    id="menu-2"
    role="menu"
    tabindex="-1"
    class="ori-menu ori-anchored ori-anchored_top"
    style="--ori-anchor: --menu-2"
    hidden
>
    <div role="menuitem" tabindex="-1" class="ori-menu__item">New file</div>
</div>
vue
<OriMenu :items="items" placement="top" @select="onSelect">
    <template #trigger="{ props }">
        <OriButton v-bind="props" label="top" variant="soft" />
    </template>
</OriMenu>
<OriMenu :items="items" placement="right" @select="onSelect">
    <template #trigger="{ props }">
        <OriButton v-bind="props" label="right" variant="soft" />
    </template>
</OriMenu>

Disabled item

A disabled item is aria-disabled, styled dimmed, and skipped by roving navigation — it can't be highlighted or selected. (There is no typeahead: the menu's keyboard contract is Arrow / Home / End / Enter / Space / Escape / Tab, with no printable-character search.)

html
<div role="menuitem" tabindex="-1" class="ori-menu__item" aria-disabled="true">Rename</div>
vue
<OriMenu
    :items="[
        { value: 'new', label: 'New file' },
        { value: 'rename', label: 'Rename', disabled: true },
        { value: 'delete', label: 'Delete' }
    ]"
    @select="onSelect"
>
    <template #trigger="{ props }">
        <OriButton v-bind="props" label="Actions" variant="soft" />
    </template>
</OriMenu>

Separator

An entry marked separator: true renders a role="separator" rule in that position instead of an item. The array is the model here, so grouping is an entry in it rather than a slotted child — the same shape PrimeVue's menu takes. A separator is not navigable and not selectable: roving steps over it exactly as it steps over a disabled item, and label on it is ignored. Its value is still used, as the list key.

html
<div role="separator" aria-orientation="horizontal" class="ori-menu__separator"></div>
vue
<OriMenu
    :items="[
        { value: 'new', label: 'New file' },
        { value: 'open', label: 'Open…' },
        { value: 'rename', label: 'Rename', disabled: true },
        { value: 'sep-1', separator: true },
        { value: 'delete', label: 'Delete' }
    ]"
    @select="onSelect"
>
    <template #trigger="{ props }">
        <OriButton v-bind="props" label="Actions" variant="soft" />
    </template>
</OriMenu>

Custom item content

Override the #item slot for icons, shortcuts, or any richer row content — the default renders label ?? value.

vue
<OriMenu :items="items" @select="onSelect">
    <template #trigger="{ props }">
        <OriButton v-bind="props" label="Actions" variant="soft" />
    </template>
    <template #item="{ item }">
        <span style="display: flex; justify-content: space-between; width: 100%">
            <span>{{ item.label }}</span>
            <span style="opacity: 0.6; font-size: 0.85em" aria-hidden="true">⌘K</span>
        </span>
    </template>
</OriMenu>

Common patterns

A toolbar "more actions" button — a typical menu-button use case.

html
<!-- Structure only — see Basic above for the full markup shape. -->
<button
    id="menu-more-trigger"
    type="button"
    style="anchor-name: --menu-more"
    aria-haspopup="menu"
    aria-controls="menu-more"
    aria-expanded="false"
    aria-label="More actions"
>
    ⋮
</button>
vue
<OriMenu
    :items="[
        { value: 'edit', label: 'Edit' },
        { value: 'duplicate', label: 'Duplicate' },
        { value: 'archive', label: 'Archive' },
        { value: 'delete', label: 'Delete' }
    ]"
    placement="bottom-end"
    @select="onAction"
>
    <template #trigger="{ props }">
        <OriButton v-bind="props" icon="M12 8a2 2 0 100-4 2 2 0 000 4zm0 6a2 2 0 100-4 2 2 0 000 4zm0 6a2 2 0 100-4 2 2 0 000 4z" aria-label="More actions" variant="text" />
    </template>
</OriMenu>

Accessibility

The accessibility contract holds across every layer — the standalone classes and the Vue component render the same attributes. Unlike the Popover, the interactive behavior here is not free from the platform: open/close, focus movement, and dismissal are driven by the @oriui/headless menu machine in JavaScript.

  • The trigger MUST be a real <button> (or a component that renders one, e.g. OriButton) and needs an accessible name (visible text or aria-label).
  • The trigger carries aria-haspopup="menu", aria-controls pointing at the panel id, and a live aria-expanded that tracks open state (unlike Popover, this one is managed — the engine owns open, not the platform).
  • The panel is role="menu", aria-labelledby the trigger, and aria-orientation="vertical". Items are role="menuitem".
  • Roving tabindex: exactly one item (the highlighted one) is tabindex="0"; the rest are tabindex="-1". Real DOM focus moves to the highlighted item as you navigate — there is no aria-activedescendant.
  • A disabled item is aria-disabled="true" and is skipped by Arrow/Home/End navigation and selection.
  • Selecting an item (click, or Enter/Space while highlighted) fires select and closes the menu, returning focus to the trigger. Escape, Tab, and an outside click also close it and return focus.
KeyAction
Enter / Space (on trigger)Opens the menu and highlights the first item.
↓ (on trigger)Opens the menu and highlights the first item.
↑ (on trigger)Opens the menu and highlights the last item.
↓ / ↑Moves the highlight to the next / previous enabled item (wraps).
Home / EndHighlights the first / last enabled item.
Enter / Space (on item)Activates the highlighted item — fires select, then closes.
EscapeCloses the menu and returns focus to the trigger.
TabCloses the menu and moves focus onward (does not trap focus).
Outside clickCloses the menu and returns focus to the trigger.

Framework API

The props, events, and slots of the Vue component. The standalone CSS layer has no component API — its surface is the classes above; the roving-tabindex keyboard and open/close behavior is JavaScript you'd need to author yourself, or get from useMenu in @oriui/headless/vue.

Props

PropTypeDefaultDescription
itemsMenuItem[]required{ value, label?, disabled?, separator? }[], in render order.
disabledbooleanfalseDisables the trigger and blocks opening the menu.
placement'top' | 'bottom' | 'left' | 'right' (each also -start / -end)'bottom-start'Placement relative to the trigger. Drives the ori-anchored_<placement> modifier.

MenuItem is the behavior layer's own type, re-exported unchanged — import it from either entry to annotate your array:

ts
import { type MenuItem } from '@oriui/vue' // or '@oriui/headless/vue'

const items: MenuItem[] = [{ value: 'copy', label: 'Copy' }, { value: 'paste' }]

Events & attributes

EventPayloadFired when
selectstringAn item is activated (click, or Enter/Space on the highlighted item) — carries the item's value. The menu closes right after.

It sets inheritAttrs: false; attributes placed on <OriMenu> fall through to the panel element, not the trigger.

Slots

SlotScopeDescription
trigger{ props: object }The control that opens the menu. Spread v-bind="props" on your own <button> (or OriButton) — it must be a button.
item{ item: MenuItem }Replaces one item's content. Falls back to item.label ?? item.value when omitted.

There is no default slot — menu content is driven entirely by items + the #item slot.