Dropdown Menu
Build a fully-featured dropdown menu component with keyboard navigation and accessibility
Learn to build a production-ready dropdown menu with keyboard navigation, accessibility, and click-outside detection. This component is perfect for navigation menus, context menus, and action lists.
What We're Building
A dropdown menu that supports:
Click to toggle open/close
Keyboard navigation (Arrow keys, Enter, Escape)
Click outside to close
Accessible with ARIA attributes
Customizable trigger and content
Positioning options
Component Code
dropdown-menu.ts
import { bindable, IEventAggregator } from 'aurelia';
import { resolve } from '@aurelia/kernel';
import { IPlatform } from '@aurelia/runtime-html';
export class DropdownMenu {
@bindable open = false;
@bindable position: 'left' | 'right' = 'left';
@bindable disabled = false;
private platform = resolve(IPlatform);
private element?: HTMLElement;
private triggerButton?: HTMLButtonElement;
private menuElement?: HTMLElement;
private clickOutsideHandler?: (e: MouseEvent) => void;
binding() {
this.setupClickOutsideHandler();
}
attaching(initiator: HTMLElement) {
this.element = initiator;
this.triggerButton = this.element.querySelector('[data-dropdown-trigger]') as HTMLButtonElement;
this.menuElement = this.element.querySelector('[data-dropdown-menu]') as HTMLElement;
}
detaching() {
this.removeClickOutsideListener();
}
toggle() {
if (this.disabled) return;
this.open = !this.open;
if (this.open) {
this.addClickOutsideListener();
this.focusFirstItem();
} else {
this.removeClickOutsideListener();
}
}
close() {
if (this.open) {
this.open = false;
this.removeClickOutsideListener();
this.triggerButton?.focus();
}
}
handleKeyDown(event: KeyboardEvent) {
if (this.disabled) return;
const { key } = event;
// Toggle on Enter or Space when trigger is focused
if ((key === 'Enter' || key === ' ') && document.activeElement === this.triggerButton) {
event.preventDefault();
this.toggle();
return;
}
// Close on Escape
if (key === 'Escape' && this.open) {
event.preventDefault();
this.close();
return;
}
// Arrow navigation when menu is open
if (this.open && (key === 'ArrowDown' || key === 'ArrowUp')) {
event.preventDefault();
this.navigateItems(key === 'ArrowDown' ? 1 : -1);
return;
}
// Activate item on Enter when focused
if (key === 'Enter' && this.open && document.activeElement?.hasAttribute('role')) {
event.preventDefault();
(document.activeElement as HTMLElement).click();
}
}
private navigateItems(direction: 1 | -1) {
if (!this.menuElement) return;
const items = Array.from(this.menuElement.querySelectorAll('[role="menuitem"]')) as HTMLElement[];
if (items.length === 0) return;
const currentIndex = items.findIndex(item => item === document.activeElement);
let nextIndex: number;
if (currentIndex === -1) {
// No item focused, focus first or last based on direction
nextIndex = direction === 1 ? 0 : items.length - 1;
} else {
// Move to next/previous item, wrapping around
nextIndex = (currentIndex + direction + items.length) % items.length;
}
items[nextIndex]?.focus();
}
private focusFirstItem() {
// Use platform.taskQueue to ensure DOM is updated
this.platform.taskQueue.queueTask(() => {
const firstItem = this.menuElement?.querySelector('[role="menuitem"]') as HTMLElement;
firstItem?.focus();
});
}
private setupClickOutsideHandler() {
this.clickOutsideHandler = (event: MouseEvent) => {
const target = event.target as Node;
if (this.element && !this.element.contains(target)) {
this.close();
}
};
}
private addClickOutsideListener() {
if (this.clickOutsideHandler) {
// Use timeout to avoid immediate close from the same click that opened it
setTimeout(() => {
document.addEventListener('click', this.clickOutsideHandler!, true);
}, 0);
}
}
private removeClickOutsideListener() {
if (this.clickOutsideHandler) {
document.removeEventListener('click', this.clickOutsideHandler, true);
}
}
/**
* Call this when an item is selected to close the menu
*/
handleItemClick() {
this.close();
}
}dropdown-menu.html
<div
class="dropdown \${open ? 'dropdown--open' : ''} dropdown--\${position}"
keydown.trigger="handleKeyDown($event)"
ref="dropdownElement">
<!-- Trigger slot -->
<button
type="button"
class="dropdown__trigger"
click.trigger="toggle()"
aria-haspopup="true"
aria-expanded.bind="open"
disabled.bind="disabled"
data-dropdown-trigger>
<au-slot name="trigger">
<span>Menu</span>
<svg class="dropdown__icon" width="12" height="12" viewBox="0 0 12 12">
<path d="M6 9L1 4h10z" fill="currentColor"/>
</svg>
</au-slot>
</button>
<!-- Menu content -->
<div
class="dropdown__menu"
role="menu"
aria-hidden.bind="!open"
data-dropdown-menu
if.bind="open">
<au-slot>
<div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 1</div>
<div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 2</div>
<div role="menuitem" tabindex="0" click.trigger="handleItemClick()">Menu Item 3</div>
</au-slot>
</div>
</div>dropdown-menu.css
.dropdown {
position: relative;
display: inline-block;
}
.dropdown__trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.dropdown__trigger:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
}
.dropdown__trigger:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.dropdown__trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dropdown__icon {
transition: transform 0.2s;
}
.dropdown--open .dropdown__icon {
transform: rotate(180deg);
}
.dropdown__menu {
position: absolute;
top: calc(100% + 4px);
min-width: 200px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 4px;
z-index: 1000;
animation: dropdown-slide-in 0.15s ease-out;
}
.dropdown--left .dropdown__menu {
left: 0;
}
.dropdown--right .dropdown__menu {
right: 0;
}
@keyframes dropdown-slide-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown__menu [role="menuitem"] {
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
outline: none;
}
.dropdown__menu [role="menuitem"]:hover,
.dropdown__menu [role="menuitem"]:focus {
background: #f3f4f6;
}
.dropdown__menu [role="menuitem"]:active {
background: #e5e7eb;
}
/* Divider */
.dropdown__divider {
height: 1px;
background: #e5e7eb;
margin: 4px 0;
}Usage Examples
Basic Dropdown
<dropdown-menu>
<div au-slot="trigger">
Actions
</div>
<div role="menuitem" tabindex="0">Edit</div>
<div role="menuitem" tabindex="0">Duplicate</div>
<div class="dropdown__divider"></div>
<div role="menuitem" tabindex="0">Delete</div>
</dropdown-menu>With Custom Trigger
<dropdown-menu position="right">
<button au-slot="trigger" class="icon-button">
<svg><!-- Settings icon --></svg>
</button>
<div role="menuitem" tabindex="0" click.trigger="openSettings()">
Settings
</div>
<div role="menuitem" tabindex="0" click.trigger="viewProfile()">
Profile
</div>
<div role="menuitem" tabindex="0" click.trigger="logout()">
Logout
</div>
</dropdown-menu>Programmatic Control
// your-component.ts
import { DropdownMenu } from './dropdown-menu';
export class YourComponent {
dropdownOpen = false;
openDropdown() {
this.dropdownOpen = true;
}
closeDropdown() {
this.dropdownOpen = false;
}
}<!-- your-component.html -->
<dropdown-menu open.bind="dropdownOpen">
<div role="menuitem" tabindex="0" click.trigger="performAction()">
Action
</div>
</dropdown-menu>
<button click.trigger="openDropdown()">Open Menu</button>Disabled State
<dropdown-menu disabled.bind="isProcessing">
<div au-slot="trigger">
Actions \${isProcessing ? '(Processing...)' : ''}
</div>
<div role="menuitem" tabindex="0">Action 1</div>
<div role="menuitem" tabindex="0">Action 2</div>
</dropdown-menu>Testing
Test your dropdown component:
import { createFixture } from '@aurelia/testing';
import { DropdownMenu } from './dropdown-menu';
describe('DropdownMenu', () => {
it('toggles open/close on trigger click', async () => {
const { component, trigger, queryBy, stop } = await createFixture
.html`<dropdown-menu></dropdown-menu>`
.deps(DropdownMenu)
.build()
.started;
expect(component.open).toBe(false);
expect(queryBy('[data-dropdown-menu]')).toBeNull();
// Click trigger to open
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(true);
// Click trigger to close
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(false);
await stop(true);
});
it('closes when clicking outside', async () => {
const { component, trigger, stop } = await createFixture
.html`
<div>
<dropdown-menu></dropdown-menu>
<button id="outside">Outside</button>
</div>
`
.deps(DropdownMenu)
.build()
.started;
// Open the dropdown
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(true);
// Click outside
trigger.click('#outside');
// Wait for click handler
await new Promise(resolve => setTimeout(resolve, 10));
expect(component.open).toBe(false);
await stop(true);
});
it('closes on Escape key', async () => {
const { component, trigger, getBy, stop } = await createFixture
.html`<dropdown-menu></dropdown-menu>`
.deps(DropdownMenu)
.build()
.started;
// Open the dropdown
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(true);
// Press Escape
trigger.keydown(getBy('.dropdown'), { key: 'Escape' });
expect(component.open).toBe(false);
await stop(true);
});
it('navigates items with arrow keys', async () => {
const { trigger, getBy, getAllBy, stop } = await createFixture
.html`
<dropdown-menu>
<div role="menuitem" tabindex="0">Item 1</div>
<div role="menuitem" tabindex="0">Item 2</div>
<div role="menuitem" tabindex="0">Item 3</div>
</dropdown-menu>
`
.deps(DropdownMenu)
.build()
.started;
// Open the dropdown
trigger.click('[data-dropdown-trigger]');
const dropdown = getBy('.dropdown');
const items = getAllBy('[role="menuitem"]');
// First item should be focused
await new Promise(resolve => setTimeout(resolve, 10));
expect(document.activeElement).toBe(items[0]);
// Arrow down to second item
trigger.keydown(dropdown, { key: 'ArrowDown' });
expect(document.activeElement).toBe(items[1]);
// Arrow up back to first
trigger.keydown(dropdown, { key: 'ArrowUp' });
expect(document.activeElement).toBe(items[0]);
await stop(true);
});
it('does not open when disabled', async () => {
const { component, trigger, stop } = await createFixture
.html`<dropdown-menu disabled.bind="true"></dropdown-menu>`
.deps(DropdownMenu)
.build()
.started;
trigger.click('[data-dropdown-trigger]');
expect(component.open).toBe(false);
await stop(true);
});
});Accessibility Features
This dropdown implements WCAG 2.1 guidelines:
✅ Keyboard Navigation: Full keyboard support with arrow keys
✅ ARIA Attributes: Proper
role,aria-haspopup,aria-expanded,aria-hidden✅ Focus Management: Focuses first item when opened, returns focus to trigger when closed
✅ Escape to Close: Standard Escape key behavior
✅ Screen Reader Support: Announces menu state and items
Enhancements
1. Add Icons to Menu Items
<div role="menuitem" tabindex="0" class="menu-item">
<svg class="menu-item__icon"><!-- Icon --></svg>
<span>Edit</span>
</div>2. Add Submenus
Nest another dropdown-menu inside:
<dropdown-menu>
<div role="menuitem" tabindex="0">Item 1</div>
<dropdown-menu position="right">
<div au-slot="trigger" role="menuitem" tabindex="0">
More Actions →
</div>
<div role="menuitem" tabindex="0">Sub Item 1</div>
<div role="menuitem" tabindex="0">Sub Item 2</div>
</dropdown-menu>
</dropdown-menu>3. Add Search/Filter
export class SearchableDropdown {
@bindable items: any[] = [];
searchTerm = '';
get filteredItems() {
return this.items.filter(item =>
item.label.toLowerCase().includes(this.searchTerm.toLowerCase())
);
}
}4. Add Positioning Intelligence
Use a library like Floating UI to automatically position the menu to avoid viewport overflow:
import { computePosition, flip, shift } from '@floating-ui/dom';
async positionMenu() {
const { x, y } = await computePosition(this.triggerButton!, this.menuElement!, {
middleware: [flip(), shift({ padding: 8 })]
});
Object.assign(this.menuElement!.style, {
left: `${x}px`,
top: `${y}px`
});
}Best Practices
Always Clean Up: Remove event listeners in
detaching()to prevent memory leaksFocus Management: Return focus to trigger when closing for better UX
Debounce: For search/filter, debounce input to avoid excessive filtering
Accessibility: Test with keyboard only and screen readers
Portal Rendering: For complex layouts, render menu in a portal to avoid z-index issues
Summary
You've built a fully-featured dropdown menu with:
✅ Click and keyboard interactions
✅ Accessibility built-in
✅ Click-outside detection
✅ Customizable trigger and content
✅ Comprehensive tests
This dropdown is production-ready and can be extended with search, submenus, and intelligent positioning!
Last updated
Was this helpful?