Getting Started
Install whichever adapter fits your stack—both share the same controller contracts.
Install
bash
pnpm add @affino/menu-vuebash
pnpm add @affino/menu-reactEach adapter bundles @affino/menu-core and ships unstyled so you can bring your own design system.
Minimal dropdown
vue
<script setup lang="ts">
import {
UiMenu,
UiMenuTrigger,
UiMenuContent,
UiMenuItem,
UiMenuSeparator,
} from '@affino/menu-vue'
const actions = [
{ id: 'rename', label: 'Rename', shortcut: 'F2' },
{ id: 'duplicate', label: 'Duplicate', shortcut: '⌘D' },
]
</script>
<template>
<UiMenu>
<UiMenuTrigger asChild>
<button class="MenuButton">File</button>
</UiMenuTrigger>
<UiMenuContent class="MenuPanel">
<UiMenuItem
v-for="action in actions"
:key="action.id"
:id="action.id"
asChild
@select="() => console.log(action.label)"
>
<button class="MenuItem">
<span>{{ action.label }}</span>
<kbd>{{ action.shortcut }}</kbd>
</button>
</UiMenuItem>
<UiMenuSeparator class="MenuSeparator" />
<UiMenuItem id="delete" danger asChild @select="() => console.log('Delete')">
<button class="MenuItem danger">Delete</button>
</UiMenuItem>
</UiMenuContent>
</UiMenu>
</template>tsx
import {
UiMenu,
UiMenuTrigger,
UiMenuContent,
UiMenuItem,
UiMenuSeparator,
} from "@affino/menu-react"
const actions = [
{ id: "rename", label: "Rename", shortcut: "F2" },
{ id: "duplicate", label: "Duplicate", shortcut: "⌘D" },
]
export function ActionsMenu() {
return (
<UiMenu>
<UiMenuTrigger asChild>
<button className="MenuButton">File</button>
</UiMenuTrigger>
<UiMenuContent className="MenuPanel">
{actions.map((action) => (
<UiMenuItem key={action.id} id={action.id} asChild onSelect={() => console.log(action.label)}>
<button className="MenuItem">
<span>{action.label}</span>
<kbd>{action.shortcut}</kbd>
</button>
</UiMenuItem>
))}
<UiMenuSeparator className="MenuSeparator" />
<UiMenuItem danger asChild onSelect={() => console.log("Delete")}>
<button className="MenuItem danger">Delete</button>
</UiMenuItem>
</UiMenuContent>
</UiMenu>
)
}Style .MenuButton, .MenuPanel, and .MenuItem with any system—Affino only supplies the behavior.
Submenus
Submenus share the same controller tree so pointer intent and focus management stay synchronized.
vue
<UiSubMenu>
<UiSubMenuTrigger asChild>
<button class="MenuItem">Share ></button>
</UiSubMenuTrigger>
<UiSubMenuContent class="MenuPanel">
<UiMenuItem asChild @select="() => console.log('Copy link')">
<button class="MenuItem">Copy link</button>
</UiMenuItem>
<UiMenuItem asChild @select="() => console.log('Email')">
<button class="MenuItem">Send email</button>
</UiMenuItem>
</UiSubMenuContent>
</UiSubMenu>tsx
<UiSubMenu>
<UiSubMenuTrigger asChild>
<button className="MenuItem">Share ></button>
</UiSubMenuTrigger>
<UiSubMenuContent className="MenuPanel">
<UiMenuItem asChild onSelect={() => console.log("Copy link")}>
<button className="MenuItem">Copy link</button>
</UiMenuItem>
<UiMenuItem asChild onSelect={() => console.log("Email")}>
<button className="MenuItem">Send email</button>
</UiMenuItem>
</UiSubMenuContent>
</UiSubMenu>Context menus
Pass trigger="contextmenu" to listen for right-click events. Programmatic menus work the same way by calling controller.setAnchor({ x, y, width: 0, height: 0 }) before controller.open('pointer').
vue
<UiMenu>
<UiMenuTrigger asChild trigger="contextmenu">
<button class="MenuButton">Right click me</button>
</UiMenuTrigger>
<UiMenuContent class="MenuPanel">
<UiMenuItem asChild @select="() => console.log('Refresh')">
<button class="MenuItem">Refresh data</button>
</UiMenuItem>
</UiMenuContent>
</UiMenu>tsx
<UiMenu>
<UiMenuTrigger trigger="contextmenu" asChild>
<button className="MenuButton">Right click me</button>
</UiMenuTrigger>
<UiMenuContent className="MenuPanel">
<UiMenuItem asChild onSelect={() => console.log("Refresh")}>
<button className="MenuItem">Refresh data</button>
</UiMenuItem>
</UiMenuContent>
</UiMenu>Need a deeper tour? See the full getting-started guide for controller internals, SSR notes, and advanced flows.