Menu
@ailuracode/alpine-menu
Headless menu store for dropdowns and context menus. Keyboard navigation (Arrow*, Home, End, Enter, Space, Escape), roving tabindex, and ARIA helpers. Only one menu open at a time by default — opening a menu closes any other open menu. No HTML or CSS is shipped.
Install
pnpm add @ailuracode/alpine-menu @ailuracode/alpine-core alpinejsQuick start
import Alpine from "alpinejs";import menu from "@ailuracode/alpine-menu";
Alpine.plugin(menu());Alpine.start();Compose scroll locking with @ailuracode/alpine-scroll (active while at least one menu is open):
menu({ scroll: Alpine.store("scroll"),});Position with @alpinejs/anchor (recommended for teleported menus):
Alpine.plugin(anchor);// x-anchor.bottom-start.fixed on the menu panelPlugin options
| Option | Default | Description |
|---|---|---|
exclusive |
true |
When opening a menu, close all other open menus |
scroll |
— | Optional @ailuracode/alpine-scroll store locked while at least one menu is open |
storeKey |
"menu" |
Alpine store key (see Avoiding name collisions) |
Avoiding name collisions
If your application already owns a $store.menu — or another toolkit plugin registers on that name — rename the integration surface without touching the controller:
Alpine.plugin(menuPlugin({ storeKey: "dropdown" })); // → $store.dropdownThe exposed constant DEFAULT_MENU_STORE_KEY keeps the rename discoverable from TypeScript.
Exclusive mode
By default (exclusive: true), open(id) and toggle(id) close every other open menu before opening the target. This is the expected behavior for dropdown menus, context menus, and similar overlays where only one panel should be visible.
// Default — no config neededAlpine.plugin(menu());When a second menu opens, the first closes automatically. Scroll lock stays in sync: replacing one open menu with another does not briefly unlock the page.
Multiple menus open
Pass exclusive: false to allow unrelated menus to stay open at the same time:
Alpine.plugin(menu({ exclusive: false }));Grouped exclusivity (menubar)
With exclusive: false, assign a group when registering menus to enforce one open menu per group — useful for a horizontal menubar without affecting global dropdowns:
Alpine.plugin(menu({ exclusive: false }));
$store.menu.register("file", { group: "menubar-1" });$store.menu.register("edit", { group: "menubar-1" });$store.menu.register("help", { group: "menubar-2" });$store.menu.register("account"); // no group — never auto-closed by group logic| Scenario | file open |
edit open |
help open |
account open |
|---|---|---|---|---|
Open edit |
closes | opens | unchanged | unchanged |
Open account |
unchanged | unchanged | unchanged | opens |
Open help then file |
opens | unchanged | unchanged | unchanged |
group only applies when plugin exclusive is false. With exclusive: true (default), every open menu is closed regardless of group.
Store API
| Method | Description |
|---|---|
register(id, options?) |
Create a menu instance (orientation, group, callbacks) |
open(id) / close(id) / toggle(id) |
Visibility; open / toggle close other menus when exclusive is enabled |
isOpen(id) |
Open state |
activeItem(id) |
Currently focused item id |
registerItem(menuId, itemId, options?) |
Register a menu item |
bindMenu(menuId, element) |
Attach the menu root for roving focus |
bindTrigger(menuId, element) |
Attach the trigger for outside-click detection |
handleOutsideClick(menuId, event) |
Close when clicking outside trigger + menu |
handleWindowOutsideClick(event, menuIds?) |
Outside-click helper for multiple menus on one page |
handleKeydown(menuId, event) |
Keyboard navigation |
handleWindowKeydown(event, menuIds?) |
Route keyboard events to the first open menu |
itemProps(menuId, itemId) |
role, tabindex, aria-disabled |
menuProps(menuId) |
role, aria-orientation |
Options per menu
| Option | Default | Description |
|---|---|---|
orientation |
"vertical" |
Arrow key axis |
closeOnSelect |
true |
Close after selectItem() |
group |
— | When plugin exclusive is false, only one menu in the same group may be open at a time |
onOpen / onClose |
— | Lifecycle callbacks |
onSelect |
— | Fired when an item is chosen (click, Enter, or Space) |
Architecture
MenuController owns all mutable menu state in a private instance registry. The Alpine plugin is a thin adapter:
- Store commands (
open,close,registerItem, …) forward to the controller. - The controller emits typed
open,close,select, andchangeevents. - The plugin copies readonly snapshots into
$store.menu.instancesso Alpine templates stay reactive.
Mutating $store.menu.instances[id] directly does not change controller state. Use store methods or subscribe to controller events for custom adapters.
Standalone usage (no Alpine)
import { createMenuController, createMenuStore, createMenuStoreFromController,} from "@ailuracode/alpine-menu";
const controller = createMenuController({ exclusive: true });controller.register("user-menu");controller.open("user-menu");
const store = createMenuStore({ exclusive: true });// or: createMenuStoreFromController(controller)| Controller API | Description |
|---|---|
hasInstance(id) |
Whether a menu id is registered |
snapshotInstances() |
Shallow readonly copies for adapter sync |
isOpen(id) / activeItem(id) |
Query methods |
Migration
| Removed / changed | Replacement |
|---|---|
MenuController.instances |
snapshotInstances() or hasInstance(id) |
constructor(instances, config) |
constructor(config) |
Commands with an instances first argument |
Same command without instances — e.g. open(id) |
controller.toStore() |
createMenuStore() or createMenuStoreFromController(controller) |
Basic markup
<div x-data x-init="$store.menu.register('user-menu', { onSelect: (id) => console.log(id) }); ['profile','settings','logout'].forEach(id => $store.menu.registerItem('user-menu', id))" @keydown.window="$store.menu.handleWindowKeydown($event, ['user-menu'])" x-on:click.window="$store.menu.handleWindowOutsideClick($event, ['user-menu'])"> <div x-ref="menuTrigger" x-init="$store.menu.bindTrigger('user-menu', $el)"> <button @click="$store.menu.toggle('user-menu')" aria-haspopup="menu">Account</button> </div>
<template x-teleport="body"> <ul x-bind="$store.menu.menuProps('user-menu')" x-init="$store.menu.bindMenu('user-menu', $el)" x-show="$store.menu.isOpen('user-menu')" x-anchor.bottom-start.offset.8.fixed="$refs.menuTrigger" class="z-50 min-w-48" > <template x-for="id in ['profile','settings','logout']" :key="id"> <li> <button x-bind="$store.menu.itemProps('user-menu', id)" @click="$store.menu.selectItem('user-menu', id)" x-text="id" ></button> </li> </template> </ul> </template></div>Accessibility
- Roving
tabindexon the active item - Vertical or horizontal orientation
Escapecloses the menuEnter/Spaceselects the active item
SSR
Register items during x-init on the client. Control visibility with x-show (or your own CSS).
Limitations
- With
exclusive: true(default), only one menu is open at a time — suitable for dropdowns and context menus - Put
@click.outsideon a wrapper that includes the trigger — not on the menu panel alone, or opening clicks will dismiss immediately - For teleported menus, use
@click.window+handleOutsideClick()so outside clicks ignore both trigger and panel - Wire
@keydown.windowwhile the menu is open;@keydownon the panel alone misses keys when focus stays on the trigger - With multiple menus on one page, use
handleWindowOutsideClick($event, menuIds)andhandleWindowKeydown($event, menuIds)on@keydown.window/x-on:click.window - Use
<template x-teleport="body">withbindTrigger()+bindMenu()when the menu sits insideoverflow-hiddenancestors - Call
bindMenu()on the menu root so arrow keys move roving focus to the active item - Position with
@alpinejs/anchor— the store does not settop/left
License
MIT
