Demo
Dropdown menu behavior with keyboard movement, menu-item focus management, and action dispatch.
Alpine.js Dropdown
huxDropdown provides menu-button behavior with open/close state, focus movement, and menu item selection handling for link and action items.
API
huxDropdown(config)
Returns an Alpine data object with:
isOpen: booleanfocusedIndex: numbermenuItems: Array<{ label: string, href?: string, action?: string, target?: string, itemDepth: number }>menuId: string | nulltriggerButtonId: string | nullmenuItemIds: string[]openMenu(): voidcloseMenu(restoreFocus?: boolean): voidtoggleMenu(): voidfocusFirstItem(): voidfocusLastItem(): voidfocusNextItem(): voidfocusPreviousItem(): voidfindMenuItem(itemLabel: string): object | nullselectItem(menuItem): void
Internal helper methods are private implementation details and are not part of the supported API contract.
Options
menuItems: Array<{ label: string, href?: string, action?: string, target?: string, children?: Array<{ label: string, href?: string, action?: string, target?: string }> }>(default:[])triggerId: string(optional, scopes generated ids and action event names)
Use hyphen-case for action names. Non-hyphen-case names will be ignored.
Quick Start
<div
x-data="huxDropdown({
triggerId: 'user-menu',
menuItems: [
{
label: 'Account',
href: '/account',
children: [{ label: 'Billing', href: '/billing' }]
},
{ label: 'Copy Link', action: 'copy-link' }
]
})"
x-on:click.outside="closeMenu()"
x-on:hux-dropdown:user-menu:copy-link.window="navigator.clipboard.writeText(location.href).catch(() => {})"
>
<button
x-ref="dropdownTrigger"
type="button"
x-bind:id="triggerButtonId"
x-bind:aria-expanded="isOpen.toString()"
x-bind:aria-controls="menuId"
aria-haspopup="menu"
x-on:click="toggleMenu()"
x-on:keydown.down.prevent="openMenu(); focusFirstItem()"
x-on:keydown.up.prevent="openMenu(); focusLastItem()"
>
Open menu
</button>
<div
x-cloak
x-show="isOpen"
role="menu"
x-bind:id="menuId"
x-bind:aria-labelledby="triggerButtonId"
>
<template x-for="(menuItem, menuIndex) in menuItems" x-bind:key="menuIndex">
<button
type="button"
role="menuitem"
x-bind:id="menuItemIds[menuIndex]"
x-bind:style="`padding-left: ${1 + menuItem.itemDepth * 0.75}rem`"
x-on:click="selectItem(menuItem)"
x-text="menuItem.label"
></button>
</template>
</div>
</div>Common Usage Patterns
Scoped Action Events
huxDropdown({
triggerId: 'options-menu',
menuItems: [{ label: 'Copy Page URL', action: 'copy-page-url' }],
})<div
x-data="huxDropdown({ triggerId: 'options-menu', menuItems: [{ label: 'Copy Page URL', action: 'copy-page-url' }] })"
x-on:hux-dropdown:options-menu:copy-page-url.window="navigator.clipboard.writeText(location.href).catch(() => {})"
>
...
</div>Internal, External, and Action Items
huxDropdown({
menuItems: [
{ label: 'Docs', href: '/patterns/dropdown' },
{ label: 'GitHub', href: 'https://github.com/markmead/hyperux', target: '_blank' },
{ label: 'Copy Page URL', action: 'copy-page-url' },
],
})Nested Heading Structure Links
huxDropdown({
triggerId: 'options-menu',
menuItems: [
{
label: 'API',
href: '#api',
children: [{ label: 'huxDropdown(config)', href: '#huxdropdownconfig' }],
},
{ label: 'Options', href: '#options' },
{ label: 'Quick Start', href: '#quick-start' },
{
label: 'Common Usage Patterns',
href: '#common-usage-patterns',
children: [
{ label: 'Scoped Action Events', href: '#scoped-action-events' },
{
label: 'Internal, External, and Action Items',
href: '#internal-external-and-action-items',
},
{ label: 'Nested Heading Structure Links', href: '#nested-heading-structure-links' },
],
},
{ label: 'Behavior Contract', href: '#behavior-contract' },
{ label: 'Error Handling', href: '#error-handling' },
{ label: 'Accessibility Notes', href: '#accessibility-notes' },
{ label: 'Notes', href: '#notes' },
],
})When you need to classify an item after lookup, resolve it first and then branch on the returned object in the same style as huxCommandPalette:
const menuItem = this.findMenuItem('GitHub')
if (menuItem?.target === '_blank') {
// external link
}Behavior Contract
menuItemsare normalized recursively; actionable entries with a label and eitherhreforactionare kept.- Nested entries are flattened into
menuItemsin depth-first order withitemDepthpreserved for rendering. - Generated ids are based on
triggerIdwhen provided; otherwise a random uuid suffix is used. closeMenu(true)restores focus to the trigger button on next tick.focusNextItem()andfocusPreviousItem()wrap around menu boundaries.findMenuItem()returns the matched item when found; otherwisenull.selectItem()dispatches named events foractionentries using hyphen-case event names and closes the menu.- Link entries close the menu without dispatching action events.
Error Handling
- Empty or invalid
menuItemsproduce an empty menu model without throwing. - Invalid
childrenvalues are ignored. - Unknown labels in
findMenuItem()returnnull. selectItem()exits early for missing items.- Missing action listeners do not interrupt menu state changes.
Accessibility Notes
- Keep trigger button wired with
aria-haspopup="menu",aria-expanded, andaria-controls. - Keep menu container
role="menu"and itemsrole="menuitem". - Use keyboard bindings for Up/Down/Home/End and Escape to maintain expected menu behavior.
- Keep action items as real
button type="button"elements; use anchors for navigation items.
Notes
- Action event names:
hux-dropdown:{event-name}— always dispatchedhux-dropdown:{triggerId}:{event-name}— additionally dispatched whentriggerIdis set
- Nested menu entries are presented as a single roving-focus list; use
itemDepthto indent child items in markup.